Explorar o código

Merge branch 'master' into node-6.9

Shane Kilkelly %!s(int64=9) %!d(string=hai) anos
pai
achega
bb65da88fe
Modificáronse 100 ficheiros con 824 adicións e 555 borrados
  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=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=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=BIN
      services/web/public/img/favicon.png
  88. BIN=BIN
      services/web/public/img/favicon@2x.png
  89. BIN=BIN
      services/web/public/img/feature-page/courtney-gibbons.jpg
  90. BIN=BIN
      services/web/public/img/feature-page/feat-accept-poster.jpg
  91. BIN=BIN
      services/web/public/img/feature-page/feat-accept.mp4
  92. BIN=BIN
      services/web/public/img/feature-page/feat-changes-poster.jpg
  93. BIN=BIN
      services/web/public/img/feature-page/feat-changes.mp4
  94. BIN=BIN
      services/web/public/img/feature-page/feat-discuss-poster.jpg
  95. BIN=BIN
      services/web/public/img/feature-page/feat-discuss.mp4
  96. BIN=BIN
      services/web/public/img/feature-page/feat-todos-poster.jpg
  97. BIN=BIN
      services/web/public/img/feature-page/feat-todos.mp4
  98. BIN=BIN
      services/web/public/img/feature-page/intro-poster.jpg
  99. BIN=BIN
      services/web/public/img/feature-page/intro.mp4
  100. BIN=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"
+Errors = require "../Errors/Errors"
+AuthenticationController = require("../Authentication/AuthenticationController")
 
 module.exports = AnalyticsController =
 	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"
 _ = require "underscore"
 request = require "request"
+Errors = require '../Errors/Errors'
 
 
 makeRequest = (opts, callback)->
@@ -10,12 +11,20 @@ makeRequest = (opts, callback)->
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
 		request opts, callback
 	else
-		callback()
-
+		callback(new Errors.ServiceNotConfiguredError('Analytics service not configured'))
 
 
 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) ->) ->
 		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")
 UserGetter = require "../User/UserGetter"
 UserUpdater = require "../User/UserUpdater"
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
 querystring = require('querystring')
 Url = require("url")
@@ -87,6 +87,7 @@ module.exports = AuthenticationController =
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					AuthenticationController._recordSuccessfulLogin(user._id)
 					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"
 					req.session.justLoggedIn = true
 					# 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")
 PublicAccessLevels = require("./PublicAccessLevels")
 Errors = require("../Errors/Errors")
+ObjectId = require("mongojs").ObjectId
+
 
 module.exports = AuthorizationManager =
 	# 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.
 	getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) ->
 		getPublicAccessLevel = () ->
+			if !ObjectId.isValid(project_id)
+				return callback(new Error("invalid project id"))
 			Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) ->
 				return callback(error) if error?
 				if !project?

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

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

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

@@ -10,7 +10,7 @@ module.exports = BlogController =
 		url = req.url?.toLowerCase()
 		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)->
 			url.indexOf(extension) != -1
@@ -42,4 +42,4 @@ module.exports = BlogController =
 		upstream = request.get(originUrl)
 		upstream.on "error", (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 = 
 			url:blogUrl
 			json:true
-			timeout:500
+			timeout:1000
 		request.get opts, (err, res, announcements)->
 			if err?
 				return callback err
@@ -18,7 +18,6 @@ module.exports = BlogHandler =
 				return callback("blog announcement returned non 200")
 			logger.log announcementsLength: announcements?.length, "announcements returned"
 			announcements = _.map announcements, (announcement)->
-				announcement.url = "/blog#{announcement.url}"
 				announcement.date = new Date(announcement.date)
 				return announcement
 			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')
 UserInfoManager = require('../User/UserInfoManager')
 UserInfoController = require('../User/UserInfoController')
-CommentsController = require('../Comments/CommentsController')
+async = require "async"
 
-module.exports =
+module.exports = ChatController =
 	sendMessage: (req, res, next)->
 		project_id = req.params.project_id
 		content = req.body.content
@@ -28,7 +28,38 @@ module.exports =
 		logger.log project_id:project_id, query:query, "getting messages"
 		ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) ->
 			return next(err) if err?
-			CommentsController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
+			ChatController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
 				return next(err) if err?
 				logger.log length: messages?.length, "sending messages to client"
 				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
 CompileManager = require("./CompileManager")
 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"
 UserGetter = require "../User/UserGetter"
 ClsiManager = require "./ClsiManager"
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
 rateLimiter = require("../../infrastructure/RateLimiter")
 

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

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

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

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

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

@@ -1,5 +1,5 @@
 logger = require('logger-sharelatex')
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 sanitize = require('sanitizer')
 ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 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')
 AuthorizationManager = require("../Authorization/AuthorizationManager")
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler")
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
@@ -96,8 +96,10 @@ module.exports = EditorHttpController =
 		EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) ->
 			if error == "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?
-				next(error)
+				res.status(500).json(req.i18n.translate('generic_something_went_wrong'))
 			else
 				res.json doc
 

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

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

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

@@ -5,5 +5,14 @@ NotFoundError = (message) ->
 	return error
 NotFoundError.prototype.__proto__ = Error.prototype
 
+
+ServiceNotConfiguredError = (message) ->
+	error = new Error(message)
+	error.name = "ServiceNotConfiguredError"
+	error.__proto__ = ServiceNotConfiguredError.prototype
+	return error
+
+
 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?
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
 				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()
 				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"))
@@ -25,10 +28,19 @@ module.exports = FileStoreHandler =
 				timeout:fiveMinsInMs
 			writeStream = request(opts)
 			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)->
 				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
+
 			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"
 				callback err
@@ -79,4 +91,4 @@ module.exports = FileStoreHandler =
 			callback(err)
 
 	_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 = 
 
-	getProjectDetails : (req, res)->
+	getProjectDetails : (req, res, next)->
 		{project_id} = req.params
 		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)
 

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

@@ -4,7 +4,7 @@ projectDeleter = require("./ProjectDeleter")
 projectDuplicator = require("./ProjectDuplicator")
 projectCreationHandler = require("./ProjectCreationHandler")
 editorController = require("../Editor/EditorController")
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 User = require('../../models/User').User
 TagsHandler = require("../Tags/TagsHandler")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
@@ -224,6 +224,11 @@ module.exports = ProjectController =
 				cb = underscore.once(cb)
 				if !user_id?
 					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
 				AnalyticsManager.getLastOccurance user_id, "shown-track-changes-onboarding-2", (error, event) ->
 					clearTimeout timeout

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

@@ -1,6 +1,6 @@
 logger = require('logger-sharelatex')
 async = require("async")
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 Settings = require('settings-sharelatex')
 ObjectId = require('mongoose').Types.ObjectId	
 Project = require('../../models/Project').Project
@@ -11,7 +11,8 @@ fs = require('fs')
 Path = require "path"
 _ = require "underscore"
 
-module.exports =
+module.exports = ProjectCreationHandler =
+
 	createBlankProject : (owner_id, projectName, callback = (error, project) ->)->
 		metrics.inc("project-creation")
 		logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
@@ -79,5 +80,10 @@ module.exports =
 				output = _.template(template.toString(), data)
 				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'
 _ = require("underscore")
 PublicAccessLevels = require("../Authorization/PublicAccessLevels")
+Errors = require("../Errors/Errors")
 
 module.exports = 
 
@@ -13,6 +14,7 @@ module.exports =
 			if err?
 				logger.err err:err, project_id:project_id, "error getting project"
 				return callback(err)
+			return callback(new Errors.NotFoundError("project not found")) if !project?
 			UserGetter.getUser project.owner_ref, (err, user) ->
 				return callback(err) if err?
 				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)->
 		setRootDoc = _.once (doc_id)->
 			projectEntityHandler.setRootDoc newProject._id, doc_id
-
-		jobs = originalFolder.docs.map (doc)->
+		docs = originalFolder.docs or []
+		jobs = docs.map (doc)->
 			return (cb)->
+				if !doc?._id?
+					return callback()
 				content = docContents[doc._id.toString()]
 				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
 					if err?
@@ -30,7 +32,8 @@ module.exports = ProjectDuplicator =
 		async.series jobs, callback
 
 	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
-		jobs = originalFolder.fileRefs.map (file)->
+		fileRefs = originalFolder.fileRefs or []
+		jobs = fileRefs.map (file)->
 			return (cb)->
 				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
 		async.parallelLimit jobs, 5, callback
@@ -40,10 +43,14 @@ module.exports = ProjectDuplicator =
 		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
 			if err?
 				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)->
+					if !childFolder?._id?
+						return cb()
 					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
 						return cb(err) if err?
 						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")
 
 module.exports = ProjectEditorHandler =
+	trackChangesAvailable: false
+
 	buildProjectModelView: (project, members, invites) ->
 		result =
 			_id        : project._id
@@ -20,11 +22,6 @@ module.exports = ProjectEditorHandler =
 		if !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)
 		result.owner = owner
 		result.members = members
@@ -38,7 +35,7 @@ module.exports = ProjectEditorHandler =
 			templates: false
 			references: false
 			trackChanges: false
-			trackChangesVisible: trackChangesVisible
+			trackChangesVisible: ProjectEditorHandler.trackChangesAvailable
 		})
 
 		return result

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

@@ -1,4 +1,5 @@
 mongojs = require("../../infrastructure/mongojs")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 async = require "async"
@@ -57,3 +58,10 @@ module.exports = ProjectGetter =
 			CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, fields, (error, readAndWriteProjects, readOnlyProjects) ->
 				return callback(error) if error?
 				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
 			if !element? && searchFolder.folders? && searchFolder.folders.length != 0
 				_.each searchFolder.folders, (folder, index)->
+					if !folder?
+						return
 					newPath = {}
 					newPath[key] = value for own key,value of path #make a value copy of the string
 					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 '/about', HomeController.externalPage("about", "About Us")
+
 		webRouter.get '/security', HomeController.externalPage("security", "Security")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide")
 		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 '/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"
 			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?
-			if data? and data.subscription?
-				recurlySubscription = data.subscription
+			if response.statusCode == 404
+				return callback null, []
 			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) ->
-		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) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "billing_info", callback
+
+	_parseXmlAndGetAttribute: (xml, attribute, callback) ->
 		RecurlyWrapper._parseXml xml, (error, data) ->
 			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
-				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) ->
 		convertDataTypes = (data) ->

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

@@ -46,31 +46,39 @@ module.exports = SubscriptionController =
 			if hasSubscription or !plan?
 				res.redirect "/user/subscription"
 			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 =
+	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)->
 		self = @
 		clientTokenId = ""
-		RecurlyWrapper.createSubscription user, subscriptionDetails, recurly_token_id, (error, recurlySubscription)->
+		@validateNoSubscriptionInRecurly user._id, (error, valid) ->
 			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?
-				callback()
+				SubscriptionUpdater.syncSubscription recurlySubscription, user._id, (error) ->
+					return callback(error) if error?
+					callback()
 
 	updateSubscription: (user, plan_code, coupon_code, callback)->
 		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: () ->
 		delete @_cachedMessages
 				
-CACHE_TIMEOUT = 5 * 60 * 1000 # 5 minutes		
+CACHE_TIMEOUT = 20 * 1000 # 20 seconds		
 setInterval () ->
 	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"
 logger = require('logger-sharelatex')
 Path = require('path')
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 
 module.exports =
 	# 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')
 Project = require('../../models/Project').Project
 keys = require('../../infrastructure/Keys')
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 request = require("request")
 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"
 logger  = require "logger-sharelatex"
-metrics = require "../../infrastructure/Metrics"
+metrics = require "metrics-sharelatex"
 fs      = require "fs"
 Path    = require "path"
 _ = require("underscore")

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

@@ -1,5 +1,5 @@
 logger  = require "logger-sharelatex"
-metrics = require "../../infrastructure/Metrics"
+metrics = require "metrics-sharelatex"
 fs      = require "fs"
 Path    = require "path"
 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')
 UserRegistrationHandler = require("./UserRegistrationHandler")
 logger = require("logger-sharelatex")
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationController = require('../Authentication/AuthenticationController')

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

@@ -1,8 +1,10 @@
 User = require("../../models/User").User
 UserLocator = require("./UserLocator")
 logger = require("logger-sharelatex")
+metrics = require('metrics-sharelatex')
 
-module.exports =
+
+module.exports = UserCreator =
 
 	getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
 		self = @
@@ -36,3 +38,9 @@ module.exports =
 
 		user.save (err)->
 			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")
+metrics = require('metrics-sharelatex')
+logger = require('logger-sharelatex')
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 
@@ -23,4 +25,11 @@ module.exports = UserGetter =
 		catch 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")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 ObjectId = mongojs.ObjectId
+logger = require('logger-sharelatex')
 
-module.exports =
+module.exports = UserLocator =
 
 	findByEmail: (email, callback)->
 		email = email.trim()
@@ -10,4 +12,10 @@ module.exports =
 			callback(err, user)
 
 	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")
 mongojs = require("../../infrastructure/mongojs")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 UserLocator = require("./UserLocator")
@@ -28,3 +29,5 @@ module.exports = UserUpdater =
 					return callback(err)
 				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 =
 	log: (req)->
 		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')
 redis = require("redis-sharelatex")
 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 = {
-	"pdfjs": "1.6.210p2"
+	"pdfjs": "1.7.225"
 	"moment": "2.9.0"
 	"ace": "1.2.5"
 }

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

@@ -1,5 +1,5 @@
 _ = require('underscore')
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 
 do trackOpenSockets = ->
 	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')
 Settings = require('settings-sharelatex')
 logger = require 'logger-sharelatex'
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 crawlerLogger = require('./CrawlerLogger')
 expressLocals = require('./ExpressLocals')
 Router = require('../router')
@@ -39,8 +39,6 @@ ErrorController = require "../Features/Errors/ErrorController"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
 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)
 

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

@@ -39,9 +39,6 @@ UserSchema = new Schema
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 						}
-	featureSwitches	  : {
-		track_changes: { type: Boolean }
-	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	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')
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
-metrics = require('./infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 ReferalController = require('./Features/Referal/ReferalController')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
@@ -40,8 +40,6 @@ AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlew
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
-CommentsController = require "./Features/Comments/CommentsController"
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -177,11 +175,6 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
 		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
 
-		webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges
-		webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers
-		webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange
-		webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges
-
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/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.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/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")}
 											span.beta-feature-badge
 										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
 									.col-md-12
 										if user.betaProgram

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

@@ -3,11 +3,13 @@ extends ../layout
 block content
 	.content
 		.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
-html(itemscope, itemtype='http://schema.org/Product')
+html.full-height(itemscope, itemtype='http://schema.org/Product')
 	head
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
 		if buildCssPath
 			link(rel='stylesheet', href=buildCssPath('/style.css'))
 		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
 			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'))
 
 		block _headLinks

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

@@ -7,13 +7,18 @@ block vars
 
 block content
 	.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
 		

+ 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-expanded': ui.reviewPanelOpen,\
 			'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")

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

@@ -32,7 +32,7 @@
 					autoplay
 					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")
 			div(ng-show="onboarding.innerStep === 2;")
 				video.feat-onboard-video(
@@ -40,7 +40,7 @@
 					autoplay
 					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")
 			div(ng-show="onboarding.innerStep === 3;")
 				video.feat-onboard-video(
@@ -48,7 +48,7 @@
 					autoplay
 					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")
 			div(ng-show="onboarding.innerStep === 4;")
 				video.feat-onboard-video(
@@ -56,7 +56,7 @@
 					autoplay
 					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")
 			button.btn.btn-primary.feat-onboard-nav-btn(
 				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(
 			ng-dblclick="!permissions.admin || startRenaming()",
 			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 }}
 
 		input.form-control(
@@ -94,7 +99,6 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			i.review-icon
 			p.toolbar-label
 				| #{translate("review")}
-				span(style="vertical-align: 20%; margin-left: 4px; padding: 2px 4px;").beta-feature-badge
 		a.btn.btn-full-height(
 			href,
 			ng-if="permissions.admin",

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

@@ -1,10 +1,18 @@
 #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
 		resolved-comments-dropdown(
 			class="rp-flex-block"
@@ -314,7 +322,7 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 	div
 		.rp-entry-callout.rp-entry-callout-add-comment
-		.rp-entry-indicator(
+		.rp-entry-indicator.rp-entry-indicator-add-comment(
 			ng-if="!commentState.adding"
 			ng-click="startNewComment(); onIndicatorClick();"
 			tooltip=translate("add_comment")
@@ -340,10 +348,10 @@ script(type='text/ng-template', id='addCommentEntryTemplate')
 						ng-keypress="handleCommentKeyPress($event);"
 						placeholder=translate("add_your_comment_here")
 						focus-on="comment:new:open"
-						ng-blur="submitNewComment()"
+						ng-blur="submitNewComment($event)"
 					)
 				.rp-entry-actions
-					button.rp-entry-button(
+					button.rp-entry-button.rp-entry-button-cancel(
 						ng-click="cancelNewComment();"
 					) 
 						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(type='text/javascript').
-		window.recomendedCurrency = '#{currency}'
 		window.countryCode = '#{countryCode}'
 		window.plan_code = '#{plan_code}'
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
-		window.couponCode = "#{couponCode}"
+		window.couponCode = !{JSON.stringify(couponCode)}
+		window.recomendedCurrency = !{JSON.stringify(currency.slice(0,3))}
 
 block content
 	.content.content-alt

+ 1 - 1
services/web/package.json

@@ -35,7 +35,7 @@
     "lynx": "0.1.1",
     "marked": "^0.3.5",
     "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",
     "mocha": "1.17.1",
     "mongojs": "2.4.0",

BIN=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 }
 			if 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 = []) ->
 			# We've just been given the current server's ranges, but need to apply any local ops we have.
 			# Reset to the server state then apply our local ops again.
-			@ranges.emit "clear"
+			@emit "ranges:clear"
 			@ranges.changes = changes
 			@ranges.comments = comments
 			@ranges.track_changes = @doc.track_changes
@@ -367,4 +373,4 @@ define [
 			for op in @doc.getPendingOp() or []
 				@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
 				@ranges.applyOp(op, { user_id: @track_changes_as })
-			@ranges.emit "redraw"
+			@emit "ranges:redraw"

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

@@ -121,11 +121,22 @@ define [
 
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 			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(
 						"Document Too Long"
 						"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
 					@ide.socket.disconnect()
 					@ide.reportError(error, meta)

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

@@ -322,10 +322,6 @@ define [
 					doc = session.getDocument()
 					doc.on "change", onChange
 
-					sharejs_doc.on "remoteop.recordRemote", (op, oldSnapshot, msg) ->
-						undoManager.nextUpdateIsRemote = true
-						trackChangesManager.nextUpdateMetaData = msg?.meta
-
 					editor.initing = true
 					sharejs_doc.attachToAce(editor)
 					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?
 				@setTrackChanges(track_changes)
 			
-			@$scope.$watch "sharejsDoc", (doc) =>
+			@$scope.$watch "sharejsDoc", (doc, oldDoc) =>
 				return if !doc?
-				@disconnectFromRangesTracker()
-				@rangesTracker = doc.ranges
-				@connectToRangesTracker()
+				if oldDoc?
+					@disconnectFromDoc(oldDoc)
+				@connectToDoc(doc)
 			
 			@$scope.$on "comment:add", (e, thread_id, offset, length) =>
 				@addCommentToSelection(thread_id, offset, length)
@@ -36,10 +36,10 @@ define [
 				@removeCommentId(comment_id)
 			
 			@$scope.$on "comment:resolve_threads", (e, thread_ids) =>
-				@resolveCommentByThreadIds(thread_ids)
+				@hideCommentsByThreadIds(thread_ids)
 			
 			@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
-				@unresolveCommentByThreadId(thread_id)
+				@showCommentByThreadId(thread_id)
 			
 			@$scope.$on "review-panel:recalculate-screen-positions", () =>
 				@recalculateReviewEntriesScreenPositions()
@@ -73,16 +73,24 @@ define [
 						_scrollTimeout = null
 					, 200
 
+			@_resetCutState()
+			onCut = () => @onCut()
+			onPaste = () => @onPaste()
+
 			bindToAce = () =>
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
 				@editor.on "changeSession", onChangeSession
+				@editor.on "cut", onCut
+				@editor.on "paste", onPaste
 				@editor.renderer.on "resize", onResize
 
 			unbindFromAce = () =>
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "change", onChangeSelection
 				@editor.off "changeSession", onChangeSession
+				@editor.off "cut", onCut
+				@editor.off "paste", onPaste
 				@editor.renderer.off "resize", onResize
 
 			@$scope.$watch "trackChangesEnabled", (enabled) =>
@@ -92,18 +100,11 @@ define [
 				else
 					unbindFromAce()
 		
-		disconnectFromRangesTracker: () ->
+		disconnectFromDoc: (doc) ->
 			@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) ->
 			if value
@@ -111,56 +112,15 @@ define [
 			else
 				@$scope.sharejsDoc?.track_changes_as = null
 		
-		connectToRangesTracker: () ->
+		connectToDoc: (doc) ->
+			@rangesTracker = doc.ranges
 			@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()
-			@rangesTracker.on "redraw", () =>
+			doc.on "ranges:redraw", () =>
 				@redrawAnnotations()
 		
 		clearAnnotations: () ->
@@ -181,6 +141,55 @@ define [
 				@_onCommentAdded(comment)
 			
 			@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) ->
 			op = { c: content, p: offset, t: thread_id }
@@ -200,6 +209,7 @@ define [
 		
 		acceptChangeId: (change_id) ->
 			@rangesTracker.removeChangeId(change_id)
+			@updateAnnotations()
 		
 		rejectChangeId: (change_id) ->
 			change = @rangesTracker.getChange(change_id)
@@ -208,21 +218,26 @@ define [
 			if change.op.d?
 				content = change.op.d
 				position = @_shareJsOffsetToAcePosition(change.op.p)
+				session.$fromReject = true # Tell track changes to cancel out delete
 				session.insert(position, content)
+				session.$fromReject = false
 			else if change.op.i?
 				start = @_shareJsOffsetToAcePosition(change.op.p)
 				end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
 				editor_text = session.getDocument().getTextRange({start, end})
 				if editor_text != change.op.i
 					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.$fromReject = false
 			else
 				throw new Error("unknown change: #{JSON.stringify(change)}")
 
 		removeCommentId: (comment_id) ->
 			@rangesTracker.removeCommentId(comment_id)
+			@updateAnnotations()
 
-		resolveCommentByThreadIds: (thread_ids) ->
+		hideCommentsByThreadIds: (thread_ids) ->
 			resolve_ids = {}
 			for id in thread_ids
 				resolve_ids[id] = true
@@ -231,12 +246,55 @@ define [
 					@_onCommentRemoved(comment)
 			@broadcastChange()
 			
-		unresolveCommentByThreadId: (thread_id) ->
+		showCommentByThreadId: (thread_id) ->
 			for comment in @rangesTracker?.comments or []
 				if comment.op.t == thread_id
 					@_onCommentAdded(comment)
 			@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: () ->
 			# TODO: reintroduce this check
 			session = @editor.getSession()
@@ -421,23 +479,18 @@ define [
 			lines = @editor.getSession().getDocument().getAllLines()
 			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) ->
 			start = @_shareJsOffsetToAcePosition(comment.op.p)
 			end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 			@_updateMarker(comment.id, start, end)
-			@editor.renderer.updateBackMarkers()
 	
 		_updateMarker: (change_id, start, end) ->
 			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
 				
 			@reset()
-			@nextUpdateIsRemote = false
 
 			@editor.on "changeSession", (e) =>
 				@reset()
+				@session = e.session
 				e.session.setUndoManager(@)
 
 		showUndoConflictWarning: () ->
@@ -38,20 +38,44 @@ define [
 				@firstUpdate = false
 				return
 			aceDeltaSets = options.args[0]
-			@session = options.args[1]
 			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 = []
-			@nextUpdateIsRemote = false
 
 		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()
 			return if !localUpdatesMade
 
@@ -206,19 +230,16 @@ define [
 						throw "Unknown delta type"
 			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) ->
 			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
 
 # 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
   getStartOffsetPosition = (start) ->
     # This is quite inefficient - getLines makes a copy of the entire
@@ -27,11 +27,11 @@ applyToShareJS = (editorDoc, delta, doc) ->
   switch delta.action
     when 'insert'
       text = delta.lines.join('\n')
-      doc.insert pos, text
+      doc.insert pos, text, fromUndo
       
     when 'remove'
       text = delta.lines.join('\n')
-      doc.del pos, text.length
+      doc.del pos, text.length, fromUndo
 
     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
         doc.emit "error", new Error("document length is greater than maxDocLength")
         return
+
+    fromUndo = !!(editor.getSession().$fromUndo or editor.getSession().$fromReject)
     
-    applyToShareJS editorDoc, change, doc
+    applyToShareJS editorDoc, change, doc, fromUndo
 
     check()
 
@@ -108,16 +110,46 @@ window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents, maxDocLength
 
     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) ->
+    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
-    editorDoc.insert offsetToPos(pos), text
+    editorDoc.applyDelta({
+        start: start,
+        end: end,
+        action: "insert",
+        lines: lines,
+        remote: true
+    });
     suppress = false
     check()
 
   doc.on 'delete', (pos, text) ->
-    suppress = true
     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
     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
   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
     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
     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"
   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.
 #
@@ -69,10 +76,10 @@ text._append = append = (newOp, c) ->
     last = newOp[newOp.length - 1]
 
     # 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
       newOp.push c
 
@@ -150,25 +157,25 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
   checkValidOp [otherC]
 
   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
     if otherC.i? # delete vs insert
       s = c.d
       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)..]
       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
       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
         append dest, c
       else
         # They overlap somewhere.
-        newC = {d:'', p:c.p}
+        newC = cloneAndModify(c, {d:''})
         if c.p < otherC.p
           newC.d = c.d[...(otherC.p - c.p)]
         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
         offset = otherC.p - c.p
         new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
-        append dest, {c:new_c, p:c.p, t: c.t}
+        append dest, cloneAndModify(c, {c:new_c})
       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?
       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
         append dest, c
       else # Delete overlaps comment
         # They overlap somewhere.
-        newC = {c:'', p:c.p, t: c.t}
+        newC = cloneAndModify(c, {c:''})
         if c.p < otherC.p
           newC.c = c.c[...(otherC.p - c.p)]
         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: (folder, path) ->
+			if !path? or !folder?
+				return null
 			parts = path.split("/")
 			name = parts.shift()
 			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
 					return
 				$scope.state.inflight = true
-				$scope.state.inflight = true
 				ide.fileTreeManager
 					.createFolder(name, parent_folder)
 					.error (e)->
 						$scope.error = e
+						$scope.state.inflight = false
 					.success () ->
 						$scope.state.inflight = false
 						$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
 		# track changes in Word. We store these as a set of ShareJs style ranges:
 		#   {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.
 		constructor: (@changes = [], @comments = []) ->
 			@setIdSeed(RangesTracker.generateIdSeed())
+			@resetDirtyState()
 
 		getIdSeed: () ->
 			return @id_seed
@@ -75,8 +79,15 @@ load = (EventEmitter) ->
 			comment = @getComment(comment_id)
 			return if !comment?
 			@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) ->
 			change = null
 			for c in @changes
@@ -89,6 +100,18 @@ load = (EventEmitter) ->
 			change = @getChange(change_id)
 			return if !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 = {}) ->
 			metadata.ts ?= new Date()
@@ -103,29 +126,37 @@ load = (EventEmitter) ->
 				@addComment(op, metadata)
 			else
 				throw new Error("unknown op type")
-			
+
+		applyOps: (ops, metadata = {}) ->
+			for op in ops
+				@applyOp(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) ->
 			for comment in @comments
 				if op.p <= comment.op.p
 					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
 					offset = op.p - comment.op.p
 					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 		applyDeleteToComments: (op) ->
 			op_start = op.p
@@ -138,7 +169,7 @@ load = (EventEmitter) ->
 				if op_end <= comment_start
 					# delete is fully before comment
 					comment.op.p -= op_length
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 				else if op_start >= comment_end
 					# delete is fully after comment, nothing to do
 				else
@@ -161,12 +192,13 @@ load = (EventEmitter) ->
 					
 					comment.op.p = Math.min(comment_start, op_start)
 					comment.op.c = remaining_before + remaining_after
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 		applyInsertToChanges: (op, metadata) ->
 			op_start = op.p
 			op_length = op.i.length
 			op_end = op.p + op_length
+			undoing = !!op.u
 
 
 			already_merged = false
@@ -184,8 +216,9 @@ load = (EventEmitter) ->
 						change.op.p += op_length
 						moved_changes.push change
 					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.p += op.i.length
 							if change.op.d == ""
@@ -203,15 +236,15 @@ load = (EventEmitter) ->
 					# Only merge inserts if they are from the same user
 					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.
 					#                   foo|<--- about to insert 'b' here
 					#  inserted 'foo'  --^ ^-- deleted 'bar'
 					# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
 					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
-					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
 					# 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
 				@_removeChange change
 			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+			for change in moved_changes
+				@_markAsDirty change, "change", "moved"
 		
 		applyDeleteToChanges: (op, metadata) ->
 			op_start = op.p
@@ -406,8 +439,8 @@ load = (EventEmitter) ->
 					@_removeChange 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) ->
 			change = {
@@ -427,17 +460,11 @@ load = (EventEmitter) ->
 				else
 					return -1
 
-			if op.d?
-				@emit "delete:added", change
-			else if op.i?
-				@emit "insert:added", change
+			@_markAsDirty(change, "change", "added")
 		
 		_removeChange: (change) ->
 			@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) ->
 			# Put in descending position order, with deleting first if at the same offset 
@@ -486,13 +513,32 @@ load = (EventEmitter) ->
 					previous_change = change
 			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 = {}
 			(clone[k] = v for k,v of object)
 			return clone
 
 if define?
-	define ["utils/EventEmitter"], load
+	define [], load
 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/review-panel/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"
 
 		$scope.SubViews =
@@ -27,6 +27,14 @@ define [
 			layoutToLeft: false
 			rendererData: {}
 			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", () ->
 			collapsedStates = {}
@@ -163,7 +171,11 @@ define [
 
 		$scope.$watch (() ->
 			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) ->
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 
@@ -288,18 +300,11 @@ define [
 
 			delete entries["add-comment"]
 			if selection
-				# Only show add comment if we're not already overlapping one
-				overlapping_comment = false
-				for id, entry of entries
-					if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
-						unless entry.offset >= selection_offset_end or entry.offset + entry.content.length <= selection_offset_start
-							overlapping_comment = true
-				if !overlapping_comment
-					entries["add-comment"] = {
-						type: "add-comment"
-						offset: selection_offset_start
-						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
 				if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
@@ -323,11 +328,17 @@ define [
 			$scope.$broadcast "change:reject", entry_id
 			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.$broadcast "comment:select_line"
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
-		
+			if $scope.shouldABAddCommentBtn and !$scope.ui.reviewPanelOpen
+				sixpack.convert "add-comment-btn"
+
 		$scope.submitNewComment = (content) ->
 			return if !content? or content == ""
 			doc_id = $scope.editor.open_doc_id
@@ -396,7 +407,7 @@ define [
 			return if !thread?
 			thread.resolved = true
 			thread.resolved_by_user = formatUser(user)
-			thread.resolved_at = new Date()
+			thread.resolved_at = new Date().toISOString()
 			$scope.reviewPanel.resolvedThreadIds[thread_id] = true
 			$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: "&"
 			onSubmit: "&"
 			onCancel: "&"
-			onIndicatorClick: "&"
+			onIndicatorClick: "&"	
 			layoutToLeft: "="
 		link: (scope, element, attrs) ->
 			scope.state =
 				isAdding: false
 				content: ""
 
+			scope.$on "comment:start_adding", () ->
+				scope.startNewComment()
+
 			scope.startNewComment = () ->
 				scope.state.isAdding = true
 				scope.onStartNew()
@@ -31,7 +34,10 @@ define [
 					if scope.state.content.length > 0 
 						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.state.isAdding = false
 				scope.state.content = ""

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

@@ -2,9 +2,13 @@ define [
 	"base"
 ], (App) ->
 	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 =
 			renaming: false
+			overflowed: false
+
 		$scope.inputs = {}
 
 		$scope.startRenaming = () ->
@@ -29,4 +33,7 @@ define [
 		$scope.$watch "project.name", (name) ->
 			if name?
 				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=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>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
services/web/public/img/brand/logo-horizontal.svg


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


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


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


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


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


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


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


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


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


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


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


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


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


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


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio