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

Merge branch 'master' into node-4.2

Henry Oswald 9 лет назад
Родитель
Сommit
bcf9a17fb3
100 измененных файлов с 3797 добавлено и 1488 удалено
  1. 45 40
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  2. 24 0
      services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee
  3. 40 0
      services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee
  4. 49 22
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 1 1
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  6. 24 0
      services/web/app/coffee/Features/Blog/BlogHandler.coffee
  7. 0 21
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  8. 23 5
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  9. 4 3
      services/web/app/coffee/Features/Docstore/DocstoreManager.coffee
  10. 4 3
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  11. 49 20
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  12. 1 1
      services/web/app/coffee/Features/Email/EmailSender.coffee
  13. 3 2
      services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee
  14. 2 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  15. 3 3
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  16. 0 26
      services/web/app/coffee/Features/Security/SessionInvalidator.coffee
  17. 9 1
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  18. 17 10
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  19. 2 3
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  20. 31 7
      services/web/app/coffee/Features/User/UserController.coffee
  21. 1 0
      services/web/app/coffee/Features/User/UserCreator.coffee
  22. 7 2
      services/web/app/coffee/Features/User/UserPagesController.coffee
  23. 21 17
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  24. 21 0
      services/web/app/coffee/Features/User/UserSessionsRedis.coffee
  25. 2 2
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  26. 4 0
      services/web/app/coffee/infrastructure/LoggerSerializers.coffee
  27. 5 1
      services/web/app/coffee/infrastructure/Modules.coffee
  28. 1 1
      services/web/app/coffee/infrastructure/PackageVersions.coffee
  29. 16 5
      services/web/app/coffee/infrastructure/Server.coffee
  30. 1 1
      services/web/app/coffee/models/User.coffee
  31. 5 1
      services/web/app/coffee/router.coffee
  32. 5 2
      services/web/app/views/general/500.jade
  33. 3 2
      services/web/app/views/layout.jade
  34. 3 0
      services/web/app/views/layout/navbar.jade
  35. 14 8
      services/web/app/views/project/editor.jade
  36. 25 5
      services/web/app/views/project/editor/editor.jade
  37. 54 0
      services/web/app/views/project/editor/feature-onboarding.jade
  38. 109 365
      services/web/app/views/project/editor/header.jade
  39. 71 32
      services/web/app/views/project/editor/history.jade
  40. 7 8
      services/web/app/views/project/editor/left-menu.jade
  41. 219 0
      services/web/app/views/project/editor/review-panel.jade
  42. 7 2
      services/web/app/views/project/editor/share.jade
  43. 40 2
      services/web/app/views/project/list.jade
  44. 9 44
      services/web/app/views/project/list/side-bar.jade
  45. 1 1
      services/web/app/views/sentry.jade
  46. 19 29
      services/web/app/views/subscriptions/dashboard.jade
  47. 136 125
      services/web/app/views/subscriptions/plans.jade
  48. 3 8
      services/web/app/views/subscriptions/successful_subscription.jade
  49. 0 1
      services/web/app/views/user/login.jade
  50. 1 1
      services/web/app/views/user/register.jade
  51. 52 21
      services/web/app/views/user/settings.jade
  52. 40 24
      services/web/config/settings.defaults.coffee
  53. 228 48
      services/web/npm-shrinkwrap.json
  54. 7 6
      services/web/package.json
  55. 3 1
      services/web/public/coffee/directives/asyncForm.coffee
  56. 20 18
      services/web/public/coffee/ide.coffee
  57. 35 0
      services/web/public/coffee/ide/FeatureOnboardingController.coffee
  58. 6 2
      services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee
  59. 44 0
      services/web/public/coffee/ide/colors/ColorManager.coffee
  60. 117 22
      services/web/public/coffee/ide/connection/ConnectionManager.coffee
  61. 3 2
      services/web/public/coffee/ide/directives/layout.coffee
  62. 29 0
      services/web/public/coffee/ide/editor/AceShareJsCodec.coffee
  63. 23 7
      services/web/public/coffee/ide/editor/Document.coffee
  64. 15 3
      services/web/public/coffee/ide/editor/EditorManager.coffee
  65. 17 2
      services/web/public/coffee/ide/editor/ShareJsDoc.coffee
  66. 18 1
      services/web/public/coffee/ide/editor/controllers/SavingNotificationController.coffee
  67. 93 17
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  68. 15 2
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee
  69. 3 28
      services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee
  70. 358 334
      services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
  71. 23 9
      services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee
  72. 4 4
      services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee
  73. 5 4
      services/web/public/coffee/ide/history/HistoryManager.coffee
  74. 2 2
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
  75. 4 19
      services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee
  76. 4 4
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  77. 4 0
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  78. 6 1
      services/web/public/coffee/ide/references/ReferencesManager.coffee
  79. 455 0
      services/web/public/coffee/ide/review-panel/ChangesTracker.coffee
  80. 9 0
      services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee
  81. 383 0
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  82. 35 0
      services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee
  83. 13 0
      services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee
  84. 23 0
      services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee
  85. 134 0
      services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee
  86. 14 0
      services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee
  87. 11 0
      services/web/public/coffee/ide/review-panel/filters/orderOverviewEntries.coffee
  88. 13 0
      services/web/public/coffee/ide/services/ide.coffee
  89. 1 1
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  90. 26 13
      services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
  91. 16 1
      services/web/public/coffee/main.coffee
  92. 27 6
      services/web/public/coffee/main/account-settings.coffee
  93. 29 9
      services/web/public/coffee/main/account-upgrade.coffee
  94. 29 0
      services/web/public/coffee/main/announcements.coffee
  95. 0 10
      services/web/public/coffee/main/event.coffee
  96. 5 2
      services/web/public/coffee/main/new-subscription.coffee
  97. 204 12
      services/web/public/coffee/main/plans.coffee
  98. 1 1
      services/web/public/coffee/main/project-list/project-list.coffee
  99. 28 17
      services/web/public/coffee/main/subscription-dashboard.coffee
  100. 22 2
      services/web/public/coffee/modules/errorCatcher.coffee

+ 45 - 40
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -1,43 +1,48 @@
-Settings = require "settings-sharelatex"
+settings = require "settings-sharelatex"
 logger = require "logger-sharelatex"
 _ = require "underscore"
+request = require "request"
 
-if !Settings.analytics?.postgres?
-	module.exports =
-		recordEvent: (user_id, event, segmentation, callback = () ->) ->
-			logger.log {user_id, event, segmentation}, "no event tracking configured, logging event"
-			callback()
-else
-	Sequelize = require "sequelize"
-	options = _.extend {logging:false}, Settings.analytics.postgres
-
-	sequelize = new Sequelize(
-		Settings.analytics.postgres.database,
-		Settings.analytics.postgres.username,
-		Settings.analytics.postgres.password,
-		options
-	)
-	
-	Event = sequelize.define("Event", {
-		user_id: Sequelize.STRING,
-		event: Sequelize.STRING,
-		segmentation: Sequelize.JSONB
-	})
-
-	module.exports =
-		recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
-			if user_id? and typeof(user_id) != "string"
-				user_id = user_id.toString()
-			if user_id == Settings.smokeTest?.userId
-				# Don't record smoke tests analytics
-				return callback()
-			Event
-				.create({ user_id, event, segmentation })
-				.then(
-					(result) -> callback(),
-					(error) ->
-						logger.err {err: error, user_id, event, segmentation}, "error recording analytics event"
-						callback(error)
-				)
-			
-		sync: () -> sequelize.sync()
+
+makeRequest = (opts, callback)->
+	if settings.apis?.analytics?.url?
+		urlPath = opts.url
+		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
+		request opts, callback
+	else
+		callback()
+
+
+
+module.exports =
+
+
+	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
+		if user_id+"" == settings.smokeTest?.userId+""
+			return callback()
+		opts =
+			body:
+				event:event
+				segmentation:segmentation
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/event"
+		makeRequest opts, callback
+
+
+	getLastOccurance: (user_id, event, callback = (error) ->) ->
+		opts =
+			body:
+				event:event
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/event/last_occurnace"
+		makeRequest opts, (err, response, body)->
+			if err? 
+				console.log response, opts
+				logger.err {user_id, err}, "error getting last occurance of event"
+				return callback err
+			else
+				return callback null, body

+ 24 - 0
services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee

@@ -0,0 +1,24 @@
+AnnouncementsHandler = require("./AnnouncementsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+
+module.exports =
+
+	getUndreadAnnouncements: (req, res, next)->
+		if !settings?.apis?.analytics?.url? or !settings.apis.blog.url?
+			return res.json []
+
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log {user_id}, "getting unread announcements"
+		AnnouncementsHandler.getUnreadAnnouncements user_id, (err, announcements)->
+			if err?
+				logger.err {err, user_id}, "unable to get unread announcements"
+				next(err)
+			else
+				res.json announcements
+
+
+
+
+

+ 40 - 0
services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee

@@ -0,0 +1,40 @@
+AnalyticsManager = require("../Analytics/AnalyticsManager")
+BlogHandler = require("../Blog/BlogHandler")
+async = require("async")
+_ = require("lodash")
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+
+module.exports =
+
+	getUnreadAnnouncements : (user_id, callback = (err, announcements)->)->
+		async.parallel {
+			lastEvent: (cb)->
+				AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb
+			announcements: (cb)->
+				BlogHandler.getLatestAnnouncements cb
+		}, (err, results)->
+			if err?
+				logger.err err:err, user_id:user_id, "error getting unread announcements"
+				return callback(err)
+			
+			announcements = _.sortBy(results.announcements, "date").reverse()
+
+			lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId
+
+			announcementIndex = _.findIndex announcements, (announcement)-> 
+				announcement.id == lastSeenBlogId
+
+			announcements = _.map announcements, (announcement, index)->
+				if announcementIndex == -1
+					read = false
+				else if index >= announcementIndex
+					read = true
+				else
+					read = false
+				announcement.read = read
+				return announcement
+
+			logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
+
+			callback null, announcements

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

@@ -30,6 +30,30 @@ module.exports = AuthenticationController =
 	deserializeUser: (user, cb) ->
 		cb(null, user)
 
+	afterLoginSessionSetup: (req, user, callback=(err)->) ->
+		req.login user, (err) ->
+			if err?
+				logger.err {user_id: user._id, err}, "error from req.login"
+				return callback(err)
+			# Regenerate the session to get a new sessionID (cookie value) to
+			# protect against session fixation attacks
+			oldSession = req.session
+			req.session.destroy (err) ->
+				if err?
+					logger.err {user_id: user._id, err}, "error when trying to destroy old session"
+					return callback(err)
+				req.sessionStore.generate(req)
+				for key, value of oldSession
+					req.session[key] = value
+				# copy to the old `session.user` location, for backward-comptability
+				req.session.user = req.session.passport.user
+				req.session.save (err) ->
+					if err?
+						logger.err {user_id: user._id}, "error saving regenerated session after login"
+						return callback(err)
+					UserSessionsManager.trackSession(user, req.sessionID, () ->)
+					callback(null)
+
 	passportLogin: (req, res, next) ->
 		# This function is middleware which wraps the passport.authenticate middleware,
 		# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
@@ -38,29 +62,18 @@ module.exports = AuthenticationController =
 			if err?
 				return next(err)
 			if user # `user` is either a user object or false
-				req.login user, (err) ->
-					# Regenerate the session to get a new sessionID (cookie value) to
-					# protect against session fixation attacks
-					oldSession = req.session
-					req.session.destroy()
-					req.sessionStore.generate(req)
-					for key, value of oldSession
-						req.session[key] = value
-					# copy to the old `session.user` location, for backward-comptability
-					req.session.user = req.session.passport.user
-					req.session.save (err) ->
-						if err?
-							logger.err {user_id: user._id}, "error saving regenerated session after login"
-							return next(err)
-						UserSessionsManager.trackSession(user, req.sessionID, () ->)
-						res.json {redir: req._redir}
+				redir = AuthenticationController._getRedirectFromSession(req) || "/project"
+				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
+					if err?
+						return next(err)
+					AuthenticationController._clearRedirectFromSession(req)
+					res.json {redir: redir}
 			else
 				res.json message: info
 		)(req, res, next)
 
 	doPassportLogin: (req, username, password, done) ->
 		email = username.toLowerCase()
-		redir = Url.parse(req?.body?.redir or "/project").path
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 			return done(err) if err?
 			if !isAllowed
@@ -73,12 +86,11 @@ module.exports = AuthenticationController =
 					UserHandler.setupLoginData(user, ()->)
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					AuthenticationController._recordSuccessfulLogin(user._id)
-					Analytics.recordEvent(user._id, "user-logged-in")
+					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
 					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
 					user._login_req_ip = req.ip
-					req._redir = redir
 					return done(null, user)
 				else
 					AuthenticationController._recordFailedLogin()
@@ -145,21 +157,23 @@ module.exports = AuthenticationController =
 		return isValid
 
 	_redirectToLoginOrRegisterPage: (req, res)->
-		if req.query.zipUrl? or req.query.project_name?
+		if (req.query.zipUrl? or
+			  req.query.project_name? or
+			  req.path == '/user/subscription/new')
 			return AuthenticationController._redirectToRegisterPage(req, res)
 		else
 			AuthenticationController._redirectToLoginPage(req, res)
 
 	_redirectToLoginPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to login page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/login?#{querystring.stringify(req.query)}"
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 
 	_redirectToRegisterPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to register page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/register?#{querystring.stringify(req.query)}"
 		res.redirect url
 		Metrics.inc "security.login-redirect"
@@ -176,3 +190,16 @@ module.exports = AuthenticationController =
 	_recordFailedLogin: (callback = (error) ->) ->
 		Metrics.inc "user.login.failed"
 		callback()
+
+	_setRedirectInSession: (req, value) ->
+		if !value?
+			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else req.path
+		if req.session?
+			req.session.postLoginRedirect = value
+
+	_getRedirectFromSession: (req) ->
+		return req?.session?.postLoginRedirect || null
+
+	_clearRedirectFromSession: (req) ->
+		if req.session?
+			delete req.session.postLoginRedirect

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

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

+ 24 - 0
services/web/app/coffee/Features/Blog/BlogHandler.coffee

@@ -0,0 +1,24 @@
+request = require "request"
+settings = require "settings-sharelatex"
+_ = require("underscore")
+logger = require "logger-sharelatex"
+
+module.exports = BlogHandler =
+
+	getLatestAnnouncements: (callback)->
+		blogUrl = "#{settings.apis.blog.url}/blog/latestannouncements.json"
+		opts = 
+			url:blogUrl
+			json:true
+			timeout:500
+		request.get opts, (err, res, announcements)->
+			if err?
+				return callback err
+			if res.statusCode != 200
+				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)

+ 0 - 21
services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee

@@ -11,27 +11,6 @@ module.exports = CollaboratorsEmailHandler =
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 		].join("&")
 
-	notifyUserOfProjectShare: (project_id, email, callback)->
-		Project
-			.findOne(_id: project_id )
-			.select("name owner_ref")
-			.populate('owner_ref')
-			.exec (err, project)->
-				emailOptions =
-					to: email
-					replyTo: project.owner_ref.email
-					project:
-						name: project.name
-						url: "#{Settings.siteUrl}/project/#{project._id}?" + [
-								"project_name=#{encodeURIComponent(project.name)}"
-								"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
-								"new_email=#{encodeURIComponent(email)}"
-								"r=#{project.owner_ref.referal_id}" # Referal
-								"rs=ci" # referral source = collaborator invite
-							].join("&")
-					owner: project.owner_ref
-				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
-
 	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
 		Project
 			.findOne(_id: project_id )

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

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

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

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

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

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

+ 49 - 20
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -7,10 +7,18 @@ settings = require("settings-sharelatex")
 
 templates = {}
 
+
 templates.registered =
 	subject:  _.template "Activate your #{settings.appName} Account"
 	layout: PersonalEmailLayout
 	type: "notification"
+	plainTextTemplate: _.template """
+Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
+
+Click here to set your password and log in: <%= setNewPasswordUrl %>
+
+If you have any questions or problems, please contact #{settings.adminEmail}
+"""
 	compiledTemplate: _.template """
 <p>Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".</p>
 
@@ -19,10 +27,24 @@ templates.registered =
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 
+
 templates.canceledSubscription =
 	subject:  _.template "ShareLaTeX thoughts"
 	layout: PersonalEmailLayout
 	type:"lifecycle"
+	plainTextTemplate: _.template """
+Hi <%= first_name %>,
+
+I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?:
+
+https://sharelatex.typeform.com/to/f5lBiZ
+
+Thank you in advance.
+
+Henry
+
+ShareLaTeX Co-founder
+"""
 	compiledTemplate: _.template '''
 <p>Hi <%= first_name %>,</p>
 
@@ -36,10 +58,26 @@ ShareLaTeX Co-founder
 </p>
 '''
 
+
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	layout: NotificationEmailLayout
 	type:"notification"
+	plainTextTemplate: _.template """
+Password Reset
+
+We got a request to reset your #{settings.appName} password.
+
+Click this link to reset your password: <%= setNewPasswordUrl %>
+
+If you ignore this message, your password won't be changed.
+
+If you didn't request a password reset, let us know.
+
+Thank you
+
+#{settings.appName} - <%= siteUrl %>
+"""
 	compiledTemplate: _.template """
 <h2>Password Reset</h2>
 <p>
@@ -66,26 +104,6 @@ If you didn't request a password reset, let us know.
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
-templates.projectSharedWithYou =
-	subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you"
-	layout: NotificationEmailLayout
-	type:"notification"
-	compiledTemplate: _.template """
-<p>Hi, <%= owner.email %> wants to share <a href="<%= project.url %>">'<%= project.name %>'</a> with you</p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= project.url %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			View Project
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
-"""
 
 templates.projectInvite =
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
@@ -113,10 +131,20 @@ Thank you
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
+
 templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	layout: NotificationEmailLayout
 	type:"notification"
+	plainTextTemplate: _.template """
+Hi, please verify your email to join the <%= group_name %> and get your free premium account
+
+Click this link to verify now: <%= completeJoinUrl %>
+
+Thank You
+
+#{settings.appName} - <%= siteUrl %>
+"""
 	compiledTemplate: _.template """
 <p>Hi, please verify your email to join the <%= group_name %> and get your free premium account</p>
 <center>
@@ -134,6 +162,7 @@ templates.completeJoinGroupAccount =
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
+
 module.exports =
 	templates: templates
 

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

@@ -25,7 +25,7 @@ else if Settings?.email?.parameters?.sendgridApiKey?
 	logger.log "using sendgrid for email"
 	nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}}))
 else if Settings?.email?.parameters?
-	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
+	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth", "ignoreTLS")
 
 
 	logger.log "using smtp for email"

+ 3 - 2
services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee

@@ -63,7 +63,8 @@ Reporter = (res) ->
 
 			res.contentType("application/json")
 			if failures.length > 0
-				res.send 500, JSON.stringify(results, null, 2)
+				logger.err failures:failures, "health check failed"
+				res.status(500).send(JSON.stringify(results, null, 2))
 			else
-				res.send 200, JSON.stringify(results, null, 2)
+				res.status(200).send(JSON.stringify(results, null, 2))
 

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

@@ -153,6 +153,8 @@ module.exports = ProjectController =
 					return next(err)
 				logger.log results:results, user_id:user_id, "rendering project list"
 				tags = results.tags[0]
+				
+
 				notifications = require("underscore").map results.notifications, (notification)->
 					notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 					return notification

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

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

+ 0 - 26
services/web/app/coffee/Features/Security/SessionInvalidator.coffee

@@ -1,26 +0,0 @@
-Settings = require("settings-sharelatex")
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
-crypto = require("crypto")
-async = require("async")
-
-
-module.exports = 
-
-	_getEmailKey : (email)->
-		hash = crypto.createHash("md5").update(email).digest("hex")
-		return "e_sess:#{hash}"
-
-	tracksession:(sessionId, email, callback = ->)->
-		session_lookup_key = @_getEmailKey(email)
-		rclient.set session_lookup_key, sessionId, callback
-
-	invalidateSession:(email, callback = ->)->
-		session_lookup_key = @_getEmailKey(email)
-		rclient.get session_lookup_key, (err, sessionId)->
-			async.series [
-				(cb)-> rclient.del sessionId, cb
-				(cb)-> rclient.del session_lookup_key, cb
-			], callback
-
-

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

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

+ 17 - 10
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -8,27 +8,34 @@ Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+UserGetter = require "../User/UserGetter"
 
 module.exports = SubscriptionController =
 
 	plansPage: (req, res, next) ->
 		plans = SubscriptionViewModelBuilder.buildViewModel()
-		if AuthenticationController.isUserLoggedIn(req)
-			baseUrl = ""
-		else
-			baseUrl = "/register?redir="
 		viewName = "subscriptions/plans"
 		if req.query.v?
 			viewName = "#{viewName}_#{req.query.v}"
 		logger.log viewName:viewName, "showing plans page"
+		currentUser = null
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 			return next(err) if err?
-			res.render viewName,
-				title: "plans_and_pricing"
-				plans: plans
-				baseUrl: baseUrl
-				gaExperiments: Settings.gaExperiments.plansPage
-				recomendedCurrency:recomendedCurrency
+			render = () ->
+				res.render viewName,
+					title: "plans_and_pricing"
+					plans: plans
+					gaExperiments: Settings.gaExperiments.plansPage
+					recomendedCurrency:recomendedCurrency
+					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
+			user_id = AuthenticationController.getLoggedInUserId(req)
+			if user_id?
+				UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->
+					return next(err) if err?
+					currentUser = user
+					render()
+			else
+				render()
 
 	#get to show the recurly.js page
 	paymentPage: (req, res, next) ->

+ 2 - 3
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -44,7 +44,7 @@ module.exports =
 		allPlans = {}
 		plans.forEach (plan)->
 			allPlans[plan.planCode] = plan
-			
+
 		result =
 			allPlans: allPlans
 
@@ -54,7 +54,7 @@ module.exports =
 
 		result.studentAccounts = _.filter plans, (plan)->
 			plan.planCode.indexOf("student") != -1
-			
+
 		result.groupMonthlyPlans = _.filter plans, (plan)->
 			plan.groupPlan and !plan.annual
 
@@ -68,4 +68,3 @@ module.exports =
 			!plan.groupPlan and plan.annual and plan.planCode.indexOf("student") == -1
 
 		return result
-

+ 31 - 7
services/web/app/coffee/Features/User/UserController.coffee

@@ -15,12 +15,32 @@ settings = require "settings-sharelatex"
 
 module.exports = UserController =
 
-	deleteUser: (req, res)->
+	tryDeleteUser: (req, res, next) ->
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		UserDeleter.deleteUser user_id, (err)->
-			if !err?
-				req.session?.destroy()
-			res.sendStatus(200)
+		password = req.body.password
+		logger.log {user_id}, "trying to delete user account"
+		if !password? or password == ''
+			logger.err {user_id}, 'no password supplied for attempt to delete account'
+			return res.sendStatus(403)
+		AuthenticationManager.authenticate {_id: user_id}, password, (err, user) ->
+			if err?
+				logger.err {user_id}, 'error authenticating during attempt to delete account'
+				return next(err)
+			if !user
+				logger.err {user_id}, 'auth failed during attempt to delete account'
+				return res.sendStatus(403)
+			UserDeleter.deleteUser user_id, (err) ->
+				if err?
+					logger.err {user_id}, "error while deleting user account"
+					return next(err)
+				sessionId = req.sessionID
+				req.logout?()
+				req.session.destroy (err) ->
+					if err?
+						logger.err err: err, 'error destorying session'
+						return next(err)
+					UserSessionsManager.untrackSession(user, sessionId)
+					res.sendStatus(200)
 
 	unsubscribe: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
@@ -30,6 +50,7 @@ module.exports = UserController =
 
 	updateUserSettings : (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
+		usingExternalAuth = settings.ldap? or settings.saml?
 		logger.log user_id: user_id, "updating account settings"
 		User.findById user_id, (err, user)->
 			if err? or !user?
@@ -60,12 +81,15 @@ module.exports = UserController =
 				user.ace.syntaxValidation = req.body.syntaxValidation
 			user.save (err)->
 				newEmail = req.body.email?.trim().toLowerCase()
-				if !newEmail? or newEmail == user.email
+				if !newEmail? or newEmail == user.email or usingExternalAuth
+					# end here, don't update email
 					AuthenticationController.setInSessionUser(req, {first_name: user.first_name, last_name: user.last_name})
 					return res.sendStatus 200
 				else if newEmail.indexOf("@") == -1
+					# email invalid
 					return res.sendStatus(400)
 				else
+					# update the user email
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
@@ -143,7 +167,7 @@ module.exports = UserController =
 									type:'success'
 									text:'Your password has been changed'
 			else
-				logger.log user: user, "current password wrong"
+				logger.log user_id: user_id, "current password wrong"
 				res.send
 					message:
 					  type:'error'

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

@@ -17,6 +17,7 @@ module.exports =
 		user = new User()
 		user.email = opts.email
 		user.holdingAccount = opts.holdingAccount
+		user.ace.syntaxValidation = true
 
 		username = opts.email.match(/^[^@]*/)
 		if opts.first_name? and opts.first_name.length != 0

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

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

+ 21 - 17
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -3,14 +3,11 @@ redis = require('redis-sharelatex')
 logger = require("logger-sharelatex")
 Async = require('async')
 _ = require('underscore')
+UserSessionsRedis = require('./UserSessionsRedis')
 
-rclient = redis.createClient(Settings.redis.web)
+rclient = UserSessionsRedis.client()
 
 module.exports = UserSessionsManager =
-
-	_sessionSetKey: (user) ->
-		return "UserSessions:#{user._id}"
-
 	# mimic the key used by the express sessions
 	_sessionKey: (sessionId) ->
 		return "sess:#{sessionId}"
@@ -23,7 +20,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to track, returning"
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogin handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 			.sadd(sessionSetKey, value)
@@ -43,7 +40,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogout handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 			.srem(sessionSetKey, value)
@@ -57,7 +54,7 @@ module.exports = UserSessionsManager =
 
 	getAllUserSessions: (user, exclude, callback=(err, sessionKeys)->) ->
 		exclude = _.map(exclude, UserSessionsManager._sessionKey)
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 				logger.err user_id: user._id, "error getting all session keys for user from redis"
@@ -66,7 +63,8 @@ module.exports = UserSessionsManager =
 			if sessionKeys.length == 0
 				logger.log {user_id: user._id}, "no other sessions found, returning"
 				return callback(null, [])
-			rclient.mget sessionKeys, (err, sessions) ->
+
+			Async.mapSeries sessionKeys, ((k, cb) -> rclient.get(k, cb)), (err, sessions) ->
 				if err?
 					logger.err {user_id: user._id}, "error getting all sessions for user from redis"
 					return callback(err)
@@ -92,7 +90,7 @@ module.exports = UserSessionsManager =
 			logger.log {}, "no user to revoke sessions for, returning"
 			return callback(null)
 		logger.log {user_id: user._id}, "revoking all existing sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
@@ -102,12 +100,18 @@ module.exports = UserSessionsManager =
 				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
 				return callback(null)
 			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
-			rclient.multi()
-				.del(keysToDelete)
-				.srem(sessionSetKey, keysToDelete)
-				.exec (err, result) ->
+
+			deletions = keysToDelete.map (k) ->
+				(cb) ->
+					rclient.del k, cb
+
+			Async.series deletions, (err, _result) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "errror revoking all sessions for user"
+					return callback(err)
+				rclient.srem sessionSetKey, keysToDelete, (err) ->
 					if err?
-						logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+						logger.err {err, user_id: user._id, sessionSetKey}, "error removing session set for user"
 						return callback(err)
 					callback(null)
 
@@ -115,7 +119,7 @@ module.exports = UserSessionsManager =
 		if !user
 			logger.log {}, "no user to touch sessions for, returning"
 			return callback(null)
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
 			if err?
 				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
@@ -127,7 +131,7 @@ module.exports = UserSessionsManager =
 			logger.log {}, "no user, returning"
 			return callback(null)
 		logger.log {user_id: user._id}, "checking sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"

+ 21 - 0
services/web/app/coffee/Features/User/UserSessionsRedis.coffee

@@ -0,0 +1,21 @@
+Settings = require 'settings-sharelatex'
+redis = require 'redis-sharelatex'
+ioredis = require 'ioredis'
+logger = require 'logger-sharelatex'
+
+redisSessionsSettings = Settings.redis.websessions or Settings.redis.web
+
+module.exports = Redis =
+	client: () ->
+		if redisSessionsSettings?.cluster?
+			logger.log {}, "using redis cluster for web sessions"
+			rclient = new ioredis.Cluster(redisSessionsSettings.cluster)
+		else
+			rclient = redis.createClient(redisSessionsSettings)
+		return rclient
+
+	sessionSetKey: (user) ->
+		if redisSessionsSettings?.cluster?
+			return "UserSessions:{#{user._id}}"
+		else
+			return "UserSessions:#{user._id}"

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

@@ -39,7 +39,7 @@ pathList = [
 	["#{jsPath}ide.js"]
 	["#{jsPath}main.js"]
 	["#{jsPath}libs.js"]
-	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js", "#{jsPath}#{ace}/snippets/latex.js"]
+	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"]
 	["#{jsPath}libs/#{pdfjs}/pdf.js"]
 	["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
 	["#{jsPath}libs/#{pdfjs}/compatibility.js"]
@@ -210,7 +210,7 @@ module.exports = (app, webRouter, apiRouter)->
 
 	webRouter.use (req, res, next)->
 		res.locals.externalAuthenticationSystemUsed = ->
-			Settings.ldap?
+			Settings.ldap? or Settings.saml?
 		next()
 
 	webRouter.use (req, res, next)->

+ 4 - 0
services/web/app/coffee/infrastructure/LoggerSerializers.coffee

@@ -1,5 +1,7 @@
 module.exports =
 	user: (user) ->
+		if !user?
+			return null
 		if !user._id?
 			user = {_id : user}
 		return {
@@ -10,6 +12,8 @@ module.exports =
 		}
 	
 	project: (project) ->
+		if !project?
+			return null
 		if !project._id?
 			project = {_id: project}
 		return {

+ 5 - 1
services/web/app/coffee/infrastructure/Modules.coffee

@@ -18,6 +18,10 @@ module.exports = Modules =
 	applyRouter: (webRouter, apiRouter) ->
 		for module in @modules
 			module.router?.apply(webRouter, apiRouter)
+
+	applyNonCsrfRouter: (webRouter, apiRouter) ->
+		for module in @modules
+			module.nonCsrfRouter?.apply(webRouter, apiRouter)
 			
 	viewIncludes: {}
 	loadViewIncludes: (app) ->
@@ -58,4 +62,4 @@ module.exports = Modules =
 				return callback(error) if error?
 				return callback null, results
 		
-Modules.loadModules()
+Modules.loadModules()

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

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

+ 16 - 5
services/web/app/coffee/infrastructure/Server.coffee

@@ -8,7 +8,9 @@ expressLocals = require('./ExpressLocals')
 Router = require('../router')
 metrics.inc("startup")
 redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+UserSessionsRedis = require('../Features/User/UserSessionsRedis')
+
+sessionsRedisClient = UserSessionsRedis.client()
 
 session = require("express-session")
 RedisStore = require('connect-redis')(session)
@@ -19,7 +21,8 @@ csrf = require('csurf')
 csrfProtection = csrf()
 cookieParser = require('cookie-parser')
 
-sessionStore = new RedisStore(client:rclient)
+# Init the session store
+sessionStore = new RedisStore(client:sessionsRedisClient)
 
 passport = require('passport')
 LocalStrategy = require('passport-local').Strategy
@@ -87,9 +90,7 @@ webRouter.use session
 		secure: Settings.secureCookie
 	store: sessionStore
 	key: Settings.cookieName
-webRouter.use csrfProtection
-webRouter.use translations.expressMiddlewear
-webRouter.use translations.setLangBasedOnDomainMiddlewear
+	rolling: true
 
 # passport
 webRouter.use passport.initialize()
@@ -106,6 +107,16 @@ passport.use(new LocalStrategy(
 passport.serializeUser(AuthenticationController.serializeUser)
 passport.deserializeUser(AuthenticationController.deserializeUser)
 
+Modules.hooks.fire 'passportSetup', passport, (err) ->
+	if err?
+		logger.err {err}, "error setting up passport in modules"
+
+Modules.applyNonCsrfRouter(webRouter, apiRouter)
+
+webRouter.use csrfProtection
+webRouter.use translations.expressMiddlewear
+webRouter.use translations.setLangBasedOnDomainMiddlewear
+
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 	req.session.touch()

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

@@ -26,7 +26,7 @@ UserSchema = new Schema
 							autoComplete:   {type : Boolean, default: true}
 							spellCheckLanguage :   {type : String, default: "en"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
-							syntaxValidation   :   {type : Boolean, default: true}
+							syntaxValidation   :   {type : Boolean}
 						}
 	features		  : {
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }

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

@@ -39,6 +39,7 @@ ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
+AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -92,7 +93,7 @@ module.exports = class Router
 		webRouter.post '/user/sessions/clear', AuthenticationController.requireLogin(), UserController.clearSessions
 
 		webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
-		webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser
+		webRouter.post '/user/delete', AuthenticationController.requireLogin(), UserController.tryDeleteUser
 
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		apiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
@@ -187,6 +188,9 @@ module.exports = class Router
 		webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
 		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead
 
+		webRouter.get '/announcements', AuthenticationController.requireLogin(), AnnouncementsController.getUndreadAnnouncements
+
+
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
 

+ 5 - 2
services/web/app/views/general/500.jade

@@ -3,7 +3,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 	head
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
-		link(rel='stylesheet', href=buildCssPath('/style.css'))
+		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
@@ -12,7 +13,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 					.col-md-8.col-md-offset-2.text-center
 						.page-header
 							h2 Oh dear, something went wrong.
-						p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
+						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

+ 3 - 2
services/web/app/views/layout.jade

@@ -51,8 +51,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			
-		block scripts
-
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
@@ -61,6 +59,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 			if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf("Googlebot") == -1) {
 				window.location.search += '&'+noCdnKey;
 			}
+
+		block scripts
+
 		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
 
 		script.

+ 3 - 0
services/web/app/views/layout/navbar.jade

@@ -35,6 +35,9 @@ nav.navbar.navbar-default
 									each child in item.dropdown
 										if child.divider
 											li.divider
+										else if child.user_email
+											li
+												div.subdued #{getUserEmail()}
 										else
 											li
 												if child.url

+ 14 - 8
services/web/app/views/project/editor.jade

@@ -15,7 +15,8 @@ block content
 				p.text-center.text-danger(ng-if="state.error").ng-cloak 
 					span(ng-bind-html="state.error")
 
-
+		include ./editor/feature-onboarding
+		
 		.global-alerts(ng-cloak)
 			.alert.alert-danger.small(ng-if="connection.forced_disconnect")
 				strong #{translate("disconnected")}
@@ -37,7 +38,7 @@ block content
 
 		include ./editor/left-menu
 
-		#chat-wrapper(
+		#chat-wrapper.full-size(
 			layout="chat",
 			spacing-open="12",
 			spacing-closed="0",
@@ -85,10 +86,16 @@ block content
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
+	script(type="text/ng-template", id="lockEditorModalTemplate")
+		.modal-header
+			h3 {{ title }}
+		.modal-body(ng-bind-html="message")
+
 block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
-	//- don't use cdn for worker
+	//- don't use cdn for workers
+	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
 	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
@@ -118,6 +125,9 @@ block requirejs
 				"ace/ext-searchbox": {
 					"deps": ["ace/ace"]
 				},
+				"ace/ext-modelist": {
+					"deps": ["ace/ace"]
+				},
 				"ace/ext-language_tools": {
 					"deps": ["ace/ace"]
 				}
@@ -129,10 +139,6 @@ block requirejs
 			}
 		};
 		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
-
-	- var aceWorkerPath = user.betaProgram ? buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) : "" // don't use cdn for worker
-
-	script(type='text/javascript').
 		window.aceWorkerPath = "#{aceWorkerPath}";
 
 	script(
@@ -142,4 +148,4 @@ block requirejs
 		src=buildJsPath('libs/require.js')
 	)
 
-	
+	

+ 25 - 5
services/web/app/views/project/editor/editor.jade

@@ -7,8 +7,19 @@ div.full-size(
 	resize-proportionally="true"
 	initial-size-east="'50%'"
 	minimum-restore-size-east="300"
+	allow-overflow-on="'center'"
 )
-	.ui-layout-center
+	.ui-layout-center(
+		ng-controller="ReviewPanelController",
+		ng-class="{\
+			'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\
+			'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
+			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
+			'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
+			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
+			'rp-size-expanded': ui.reviewPanelOpen\
+		}"
+	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 			span(ng-show="editor.open_doc_id")
 				i.fa.fa-spin.fa-refresh
@@ -24,7 +35,7 @@ div.full-size(
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			auto-complete="settings.autoComplete",
-			spell-check="true",
+			spell-check="!anonymous",
 			spell-check-language="project.spellCheckLanguage",
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			show-print-margin="false",
@@ -32,12 +43,21 @@ div.full-size(
 			last-updated="editor.last_updated",
 			cursor-position="editor.cursorPosition",
 			goto-line="editor.gotoLine",
-			resize-on="layout:main:resize,layout:pdf:resize",
+			resize-on="layout:main:resize,layout:pdf:resize,layout:review:resize,review-panel:toggle",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			read-only="!permissions.write",
-			on-ctrl-enter="recompileViaKey"
-			syntax-validation="settings.syntaxValidation"
+			file-name="editor.open_doc_name",
+			on-ctrl-enter="recompileViaKey",
+			syntax-validation="settings.syntaxValidation",
+			review-panel="reviewPanel",
+			events-bridge="reviewPanelEventsBridge"
+			track-changes-enabled="trackChangesFeatureFlag",
+			track-new-changes= "reviewPanel.trackNewChanges",
+			changes-tracker="reviewPanel.changesTracker",
+			doc-id="editor.open_doc_id"
 		)
+		
+		include ./review-panel
 
 	.ui-layout-east
 		div(ng-if="ui.pdfLayout == 'sideBySide'")

+ 54 - 0
services/web/app/views/project/editor/feature-onboarding.jade

@@ -0,0 +1,54 @@
+.feat-onboard(
+	ng-controller="FeatureOnboardingController"
+	ng-class="('feat-onboard-step' + innerStep)"
+	ng-if="!state.loading && ui.showCodeCheckerOnboarding"
+	ng-cloak
+)
+	.feat-onboard-wrapper
+		h1.feat-onboard-title
+			| Introducing&nbsp;
+			span.feat-onboard-title-name Code check
+		div(ng-if="innerStep === 1;")
+			p.feat-onboard-description
+				span.feat-onboard-description-name Code check&nbsp;
+				| will highlight potential problems in your LaTeX code, allowing you to handle errors earlier and become more productive.
+			.row
+				video.feat-onboard-video(autoplay, loop)
+					source(src="/img/teasers/code-checker/code-checker.mp4", type="video/mp4")
+					img(src="/img/teasers/code-checker/code-checker.gif")
+			.row.feat-onboard-adv-wrapper
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Missing&nbsp;
+						span.feat-onboard-adv-title-highlight brackets
+					p Forgot to place a closing bracket? We'll warn you.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Unclosed&nbsp;
+						span.feat-onboard-adv-title-highlight environments
+					p 
+						| Know when you are missing an&nbsp;
+						code \end{...}
+						| &nbsp;command.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Incorrect&nbsp;
+						span.feat-onboard-adv-title-highlight nesting
+					p 
+						| Order matters. Get notified when you use an&nbsp;
+						code \end{...}
+						| &nbsp; too soon.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="turnCodeCheckOn();") Yes, turn Code check on
+			.feat-onboard-btn-wrapper
+				button.btn.btn-default(ng-click="turnCodeCheckOff();") No, disable it for now
+		div(ng-if="innerStep === 2;")
+			p.feat-onboard-description
+				| Remember: you can always turn&nbsp;
+				span.feat-onboard-description-name Code check&nbsp;
+				em on&nbsp;
+				| or&nbsp;
+				em off&nbsp;
+				|, in the settings menu.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="dismiss();") OK, got it

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

@@ -1,380 +1,124 @@
-div(ng-if="!shouldABTestHeaderLabels")
-	header.toolbar.toolbar-header(
-		ng-cloak,
-		ng-hide="state.loading"
-	)
+header.toolbar.toolbar-header.toolbar-with-labels(
+	ng-cloak,
+	ng-hide="state.loading"
+)
+	.toolbar-left
 		a.btn.btn-full-height(
 			href,
-			ng-click="ui.leftMenuShown = true"
-			tooltip='#{translate("menu")}',
-			tooltip-placement="bottom",
-			tooltip-append-to-body="true",
+			ng-click="ui.leftMenuShown = true;",
 		)
 			i.fa.fa-fw.fa-bars
+			p.toolbar-label #{translate("menu")}
 		a(
 			href="/project"
-			tooltip="#{translate('back_to_projects')}",
-			tooltip-placement="bottom",
-			tooltip-append-to-body="true"
 		)
 			i.fa.fa-fw.fa-level-up
-		
-		span(ng-controller="PdfViewToggleController")
-			a(
-				href,
-				ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
-				tooltip="PDF",
-				tooltip-placement="bottom",
-				tooltip-append-to-body="true",
-				ng-click="togglePdfView()",
-				ng-class="{ 'active': ui.view == 'pdf' }"
-			)
-				i.fa.fa-file-pdf-o
-
-		.toolbar-center.project-name(ng-controller="ProjectNameController")
-			span.name(
-				ng-dblclick="!permissions.admin || startRenaming()",
-				ng-show="!state.renaming"
-			) {{ project.name }}
-
-			input.form-control(
-				type="text"
-				ng-model="inputs.name",
-				ng-show="state.renaming",
-				on-enter="finishRenaming()",
-				ng-blur="finishRenaming()",
-				select-name-when="state.renaming"
-			)
-
-			a.rename(
-				ng-if="permissions.admin",
-				href='#',
-				tooltip-placement="bottom",
-				tooltip="#{translate('rename')}",
-				tooltip-append-to-body="true",
-				ng-click="startRenaming()",
-				ng-show="!state.renaming"
-			)
-				i.fa.fa-pencil
 
-		.toolbar-right
-			span.online-users(
-				ng-show="onlineUsersArray.length > 0"
-				ng-controller="OnlineUsersController"
-			)
-				span(ng-if="onlineUsersArray.length < 4")
-					span.online-user(
-						ng-repeat="user in onlineUsersArray",
-						ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
-						popover="{{ user.name }}"
-						popover-placement="bottom"
-						popover-append-to-body="true"
-						popover-trigger="mouseenter"
-						ng-click="gotoUser(user)"
-					) {{ user.name.slice(0,1) }}
-				
-				span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
-					span.online-user.online-user-multi(
-						dropdown-toggle,
-						tooltip="#{translate('connected_users')}",
-						tooltip-placement="left"
-					)
-						strong {{ onlineUsersArray.length }}
-						i.fa.fa-fw.fa-user
-					ul.dropdown-menu.pull-right
-						li.dropdown-header #{translate('connected_users')}
-						li(ng-repeat="user in onlineUsersArray")
-							a(href, ng-click="gotoUser(user)")
-								span.online-user(
-									ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
-								) {{ user.name.slice(0,1) }}
-								| {{ user.name }}
-
-		
-			a.btn.btn-full-height(
-				href,
-				ng-if="permissions.admin",
-				tooltip="#{translate('share')}",
-				tooltip-placement="bottom",
-				ng-click="openShareProjectModal()",
-				ng-controller="ShareController",
-			)
-				i.fa.fa-fw.fa-group
-			a.btn.btn-full-height(
-				href,
-				ng-click="toggleHistory()",
-				ng-class="{ active: (ui.view == 'history') }"
-				tooltip="#{translate('recent_changes')}",
-				tooltip-placement="bottom",
-			)
-				i.fa.fa-fw.fa-history
-			a.btn.btn-full-height(
-				href,
-				tooltip="#{translate('chat')}",
-				tooltip-placement="bottom",
-				ng-class="{ active: ui.chatOpen }",
-				ng-click="toggleChat()",
-				ng-controller="ChatButtonController",
-				ng-show="!anonymous",
-			)
-				i.fa.fa-fw.fa-comment(
-					ng-class="{ 'bounce': unreadMessages > 0 }"
-				)
-				span.label.label-info(
-					ng-show="unreadMessages > 0"
-				) {{ unreadMessages }}
-
-div(ng-if="shouldABTestHeaderLabels")
-	div(sixpack-switch="editor-header")
-		header.toolbar.toolbar-header(
-			ng-cloak,
-			ng-hide="state.loading"
-			sixpack-default
+	span(ng-controller="PdfViewToggleController")
+		a(
+			href,
+			ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+			tooltip="PDF",
+			tooltip-placement="bottom",
+			tooltip-append-to-body="true",
+			ng-click="togglePdfView()",
+			ng-class="{ 'active': ui.view == 'pdf' }"
+		)
+			i.fa.fa-file-pdf-o
+
+	.toolbar-center.project-name(ng-controller="ProjectNameController")
+		span.name(
+			ng-dblclick="!permissions.admin || startRenaming()",
+			ng-show="!state.renaming"
+		) {{ project.name }}
+
+		input.form-control(
+			type="text"
+			ng-model="inputs.name",
+			ng-show="state.renaming",
+			on-enter="finishRenaming()",
+			ng-blur="finishRenaming()",
+			select-name-when="state.renaming"
 		)
-			a.btn.btn-full-height(
-				href,
-				ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');"
-				tooltip='#{translate("menu")}',
-				tooltip-placement="bottom",
-				tooltip-append-to-body="true",
-				sixpack-convert="editor-header"
-			)
-				i.fa.fa-fw.fa-bars
-			a(
-				href="/project"
-				tooltip="#{translate('back_to_projects')}",
-				tooltip-placement="bottom",
-				tooltip-append-to-body="true"
-			)
-				i.fa.fa-fw.fa-level-up
-			
-			span(ng-controller="PdfViewToggleController")
-				a(
-					href,
-					ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
-					tooltip="PDF",
-					tooltip-placement="bottom",
-					tooltip-append-to-body="true",
-					ng-click="togglePdfView()",
-					ng-class="{ 'active': ui.view == 'pdf' }"
-				)
-					i.fa.fa-file-pdf-o
-
-			.toolbar-center.project-name(ng-controller="ProjectNameController")
-				span.name(
-					ng-dblclick="!permissions.admin || startRenaming()",
-					ng-show="!state.renaming"
-				) {{ project.name }}
-
-				input.form-control(
-					type="text"
-					ng-model="inputs.name",
-					ng-show="state.renaming",
-					on-enter="finishRenaming()",
-					ng-blur="finishRenaming()",
-					select-name-when="state.renaming"
-				)
-
-				a.rename(
-					ng-if="permissions.admin",
-					href='#',
-					tooltip-placement="bottom",
-					tooltip="#{translate('rename')}",
-					tooltip-append-to-body="true",
-					ng-click="startRenaming()",
-					ng-show="!state.renaming"
-				)
-					i.fa.fa-pencil
 
-			.toolbar-right
-				span.online-users(
-					ng-show="onlineUsersArray.length > 0"
-					ng-controller="OnlineUsersController"
-				)
-					span(ng-if="onlineUsersArray.length < 4")
-						span.online-user(
-							ng-repeat="user in onlineUsersArray",
-							ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
-							popover="{{ user.name }}"
-							popover-placement="bottom"
-							popover-append-to-body="true"
-							popover-trigger="mouseenter"
-							ng-click="gotoUser(user)"
-						) {{ user.name.slice(0,1) }}
-					
-					span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
-						span.online-user.online-user-multi(
-							dropdown-toggle,
-							tooltip="#{translate('connected_users')}",
-							tooltip-placement="left"
-						)
-							strong {{ onlineUsersArray.length }}
-							i.fa.fa-fw.fa-user
-						ul.dropdown-menu.pull-right
-							li.dropdown-header #{translate('connected_users')}
-							li(ng-repeat="user in onlineUsersArray")
-								a(href, ng-click="gotoUser(user)")
-									span.online-user(
-										ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
-									) {{ user.name.slice(0,1) }}
-									| {{ user.name }}
+		a.rename(
+			ng-if="permissions.admin",
+			href='#',
+			tooltip-placement="bottom",
+			tooltip="#{translate('rename')}",
+			tooltip-append-to-body="true",
+			ng-click="startRenaming()",
+			ng-show="!state.renaming"
+		)
+			i.fa.fa-pencil
 
+	.toolbar-right
+		span.online-users(
+			ng-show="onlineUsersArray.length > 0"
+			ng-controller="OnlineUsersController"
+		)
+			span(ng-if="onlineUsersArray.length < 4")
+				span.online-user(
+					ng-repeat="user in onlineUsersArray",
+					ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
+					popover="{{ user.name }}"
+					popover-placement="bottom"
+					popover-append-to-body="true"
+					popover-trigger="mouseenter"
+					ng-click="gotoUser(user)"
+				) {{ user.name.slice(0,1) }}
 			
-				a.btn.btn-full-height(
-					href,
-					ng-if="permissions.admin",
-					tooltip="#{translate('share')}",
-					tooltip-placement="bottom",
-					ng-click="openShareProjectModal(); trackABTestConversion('share');",
-					ng-controller="ShareController",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-group
-				a.btn.btn-full-height(
-					href,
-					ng-click="toggleHistory(); trackABTestConversion('history');",
-					ng-class="{ active: (ui.view == 'history') }"
-					tooltip="#{translate('recent_changes')}",
-					tooltip-placement="bottom",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-history
-				a.btn.btn-full-height(
-					href,
-					tooltip="#{translate('chat')}",
-					tooltip-placement="bottom",
-					ng-class="{ active: ui.chatOpen }",
-					ng-click="toggleChat(); trackABTestConversion('chat');",
-					ng-controller="ChatButtonController",
-					ng-show="!anonymous",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-comment(
-						ng-class="{ 'bounce': unreadMessages > 0 }"
-					)
-					span.label.label-info(
-						ng-show="unreadMessages > 0"
-					) {{ unreadMessages }}
+			span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
+				span.online-user.online-user-multi(
+					dropdown-toggle,
+					tooltip="#{translate('connected_users')}",
+					tooltip-placement="left"
+				)
+					strong {{ onlineUsersArray.length }}
+					i.fa.fa-fw.fa-user
+				ul.dropdown-menu.pull-right
+					li.dropdown-header #{translate('connected_users')}
+					li(ng-repeat="user in onlineUsersArray")
+						a(href, ng-click="gotoUser(user)")
+							span.online-user(
+								ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
+							) {{ user.name.slice(0,1) }}
+							| {{ user.name }}
 
-		header.toolbar.toolbar-header.toolbar-with-labels(
-			ng-cloak,
-			ng-hide="state.loading"
-			sixpack-when="labels"
+		a.btn.btn-full-height(
+			href,
+			ng-if="trackChangesFeatureFlag",
+			ng-class="{ active: ui.reviewPanelOpen }"
+			ng-click="toggleReviewPanel()"
 		)
-			.toolbar-left
-				a.btn.btn-full-height(
-					href,
-					ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-bars
-					p.toolbar-label #{translate("menu")}
-				a(
-					href="/project"
-				)
-					i.fa.fa-fw.fa-level-up
-
-			span(ng-controller="PdfViewToggleController")
-				a(
-					href,
-					ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
-					tooltip="PDF",
-					tooltip-placement="bottom",
-					tooltip-append-to-body="true",
-					ng-click="togglePdfView()",
-					ng-class="{ 'active': ui.view == 'pdf' }"
-				)
-					i.fa.fa-file-pdf-o
-
-			.toolbar-center.project-name(ng-controller="ProjectNameController")
-				span.name(
-					ng-dblclick="!permissions.admin || startRenaming()",
-					ng-show="!state.renaming"
-				) {{ project.name }}
-
-				input.form-control(
-					type="text"
-					ng-model="inputs.name",
-					ng-show="state.renaming",
-					on-enter="finishRenaming()",
-					ng-blur="finishRenaming()",
-					select-name-when="state.renaming"
-				)
-
-				a.rename(
-					ng-if="permissions.admin",
-					href='#',
-					tooltip-placement="bottom",
-					tooltip="#{translate('rename')}",
-					tooltip-append-to-body="true",
-					ng-click="startRenaming()",
-					ng-show="!state.renaming"
-				)
-					i.fa.fa-pencil
-
-			.toolbar-right
-				span.online-users(
-					ng-show="onlineUsersArray.length > 0"
-					ng-controller="OnlineUsersController"
-				)
-					span(ng-if="onlineUsersArray.length < 4")
-						span.online-user(
-							ng-repeat="user in onlineUsersArray",
-							ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
-							popover="{{ user.name }}"
-							popover-placement="bottom"
-							popover-append-to-body="true"
-							popover-trigger="mouseenter"
-							ng-click="gotoUser(user)"
-						) {{ user.name.slice(0,1) }}
-					
-					span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
-						span.online-user.online-user-multi(
-							dropdown-toggle,
-							tooltip="#{translate('connected_users')}",
-							tooltip-placement="left"
-						)
-							strong {{ onlineUsersArray.length }}
-							i.fa.fa-fw.fa-user
-						ul.dropdown-menu.pull-right
-							li.dropdown-header #{translate('connected_users')}
-							li(ng-repeat="user in onlineUsersArray")
-								a(href, ng-click="gotoUser(user)")
-									span.online-user(
-										ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
-									) {{ user.name.slice(0,1) }}
-									| {{ user.name }}
-
-			
-				a.btn.btn-full-height(
-					href,
-					ng-if="permissions.admin",
-					ng-click="openShareProjectModal(); trackABTestConversion('share');",
-					ng-controller="ShareController",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-group
-					p.toolbar-label #{translate("share")}
-				a.btn.btn-full-height(
-					href,
-					ng-click="toggleHistory(); trackABTestConversion('history');",
-					ng-class="{ active: (ui.view == 'history') }",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-history
-					p.toolbar-label #{translate("history")}
-				a.btn.btn-full-height(
-					href,
-					ng-class="{ active: ui.chatOpen }",
-					ng-click="toggleChat(); trackABTestConversion('chat');",
-					ng-controller="ChatButtonController",
-					ng-show="!anonymous",
-					sixpack-convert="editor-header"
-				)
-					i.fa.fa-fw.fa-comment(
-						ng-class="{ 'bounce': unreadMessages > 0 }"
-					)
-					span.label.label-info(
-						ng-show="unreadMessages > 0"
-					) {{ unreadMessages }}
-					p.toolbar-label #{translate("chat")}
+			i.review-icon
+			p.toolbar-label Review
+		a.btn.btn-full-height(
+			href,
+			ng-if="permissions.admin",
+			ng-click="openShareProjectModal();",
+			ng-controller="ShareController",
+		)
+			i.fa.fa-fw.fa-group
+			p.toolbar-label #{translate("share")}
+		a.btn.btn-full-height(
+			href,
+			ng-click="toggleHistory();",
+			ng-class="{ active: (ui.view == 'history') }",
+		)
+			i.fa.fa-fw.fa-history
+			p.toolbar-label #{translate("history")}
+		a.btn.btn-full-height(
+			href,
+			ng-class="{ active: ui.chatOpen }",
+			ng-click="toggleChat();",
+			ng-controller="ChatButtonController",
+			ng-show="!anonymous",
+		)
+			i.fa.fa-fw.fa-comment(
+				ng-class="{ 'bounce': unreadMessages > 0 }"
+			)
+			span.label.label-info(
+				ng-show="unreadMessages > 0"
+			) {{ unreadMessages }}
+			p.toolbar-label #{translate("chat")}

+ 71 - 32
services/web/app/views/project/editor/history.jade

@@ -1,41 +1,80 @@
 div#history(ng-show="ui.view == 'history'")
 	span(ng-controller="HistoryPremiumPopup")
-		.upgrade-prompt(ng-show="!project.features.versioning")
-			.message(ng-show="project.owner._id == user.id")
-				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
-				p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-				ul.list-unstyled
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("unlimited_projects")}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("full_doc_history")}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_dropbox")}
+		.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
+			
+			div(ng-if="project.owner._id == user.id")
+				div(sixpack-switch="teaser-history")
+					.message(sixpack-default)
+						p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
+						p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+						ul.list-unstyled
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("unlimited_projects")}
+							
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("full_doc_history")}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_dropbox")}
 
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_github")}
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_github")}
 
-					li  
-						i.fa.fa-check &nbsp;
-						|#{translate("compile_larger_projects")}
+							li  
+								i.fa.fa-check &nbsp;
+								|#{translate("compile_larger_projects")}
+						p.text-center(ng-controller="FreeTrialModalController")
+							a.btn.btn-success(
+								href
+								ng-class="buttonClass"
+								ng-click="startFreeTrial('history')"
+								sixpack-convert="teaser-history"
+							) #{translate("start_free_trial")}
 
-				p.text-center(ng-controller="FreeTrialModalController")
-					a.btn.btn-success(
-						href
-						ng-class="buttonClass"
-						ng-click="startFreeTrial('history')"
-					) #{translate("start_free_trial")}
+					.message.message-wider(sixpack-when="focused")
+						header.message-header
+							h3 History
 
+						.message-body
+							h4.teaser-title See who changed what. Go back to previous versions.
+							img.teaser-img(
+								src="/img/teasers/history/teaser-history.png"
+								alt="History"
+							)
+							p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+							.row
+								.col-md-8.col-md-offset-2
+									ul.list-unstyled
+										li 
+											i.fa.fa-check &nbsp;
+											| Catch up with your collaborators changes
+										
+										li
+											i.fa.fa-check &nbsp;
+											| See changes over any time period
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Revert your documents to previous versions
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Restore deleted files
+							p.text-center(ng-controller="FreeTrialModalController")
+								a.btn.btn-success(
+									href
+									ng-class="buttonClass"
+									ng-click="startFreeTrial('history')"
+									sixpack-convert="teaser-history"
+								) Try it for free
 
 			.message(ng-show="project.owner._id != user.id")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}

+ 7 - 8
services/web/app/views/project/editor/left-menu.jade

@@ -105,14 +105,13 @@ aside#left-menu.full-size(
 					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
 				)
 
-			if (user.betaProgram)
-				.form-controls
-					label(for="syntaxValidation") #{translate("syntax_validation")}
-					select(
-						name="syntaxValidation"
-						ng-model="settings.syntaxValidation"
-						ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
-					)
+			.form-controls.code-check-setting
+				label(for="syntaxValidation") #{translate("syntax_validation")}
+				select(
+					name="syntaxValidation"
+					ng-model="settings.syntaxValidation"
+					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
+				)
 
 			.form-controls
 				label(for="theme") #{translate("theme")}

+ 219 - 0
services/web/app/views/project/editor/review-panel.jade

@@ -0,0 +1,219 @@
+#review-panel
+	.review-panel-toolbar
+		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = true;", ng-if="reviewPanel.trackNewChanges === false") Track Changes is 
+			strong off
+		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = false;", ng-if="reviewPanel.trackNewChanges === true") Track Changes is 
+			strong on
+		review-panel-toggle(ng-model="reviewPanel.trackNewChanges")
+
+	.rp-entry-list(
+		review-panel-sorted
+		ng-if="reviewPanel.subView === SubViews.CUR_FILE"
+	)
+		.rp-entry-list-inner
+			.rp-entry-wrapper(
+				ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
+			)
+				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
+					change-entry(
+						entry="entry"
+						user="users[entry.metadata.user_id]"
+						on-reject="rejectChange(entry_id);"
+						on-accept="acceptChange(entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+					)
+
+				div(ng-if="entry.type === 'comment'")
+					comment-entry(
+						entry="entry"
+						users="users"
+						on-resolve="resolveComment(entry, entry_id)"
+						on-unresolve="unresolveComment(entry_id)"
+						on-show-thread="showThread(entry)"
+						on-hide-thread="hideThread(entry)"
+						on-delete="deleteComment(entry_id)"
+						on-reply="submitReply(entry, entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+					)
+				
+				div(ng-if="entry.type === 'add-comment'")
+					add-comment-entry(
+						on-start-new="startNewComment();"
+						on-submit="submitNewComment(content);"
+						on-cancel="cancelNewComment();"
+						on-indicator-click="toggleReviewPanel();"
+					)
+
+	.rp-entry-list(
+		ng-if="reviewPanel.subView === SubViews.OVERVIEW"
+	)
+		.rp-overview-file(
+			ng-repeat="(doc_id, entries) in reviewPanel.entries"
+		)
+			.rp-overview-file-header
+				| {{ getFileName(doc_id) }}
+			.rp-entry-wrapper(
+				ng-repeat="(entry_id, entry) in entries | orderOverviewEntries"
+			)
+				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
+					change-entry(
+						entry="entry"
+						user="users[entry.metadata.user_id]"
+						on-reject="rejectChange(entry.id);"
+						on-accept="acceptChange(entry.id);"
+						on-indicator-click="toggleReviewPanel();"
+						ng-click="gotoEntry(doc_id, entry)"
+					)
+
+				div(ng-if="entry.type === 'comment'")
+					comment-entry(
+						entry="entry"
+						users="users"
+						on-resolve="resolveComment(entry, entry.id)"
+						on-unresolve="unresolveComment(entry.id)"
+						on-delete="deleteComment(entry.id)"
+						on-reply="submitReply(entry, entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+						ng-click="gotoEntry(doc_id, entry)"
+					)
+
+	.rp-nav
+		a.rp-nav-item(
+			href
+			ng-click="setSubView(SubViews.CUR_FILE);"
+			ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.CUR_FILE }"
+		)
+			i.fa.fa-file-text-o
+			span.rp-nav-label Current file
+		a.rp-nav-item(
+			href
+			ng-click="setSubView(SubViews.OVERVIEW);"
+			ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.OVERVIEW }"
+		)
+			i.fa.fa-list
+			span.rp-nav-label Overview
+
+
+script(type='text/ng-template', id='changeEntryTemplate')
+	div
+		.rp-entry-callout(
+			ng-class="'rp-entry-callout-' + entry.type"
+		)
+		.rp-entry-indicator(
+			ng-switch="entry.type"
+			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
+			ng-click="onIndicatorClick();"
+		)
+			i.fa.fa-pencil(ng-switch-when="insert")
+			i.rp-icon-delete(ng-switch-when="delete")
+		.rp-entry(
+			ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]"
+		)
+			.rp-entry-header
+				.rp-entry-action-icon(ng-switch="entry.type")
+					i.fa.fa-pencil(ng-switch-when="insert")
+					i.rp-icon-delete(ng-switch-when="delete")
+				.rp-entry-metadata
+					p.rp-entry-metadata-line(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} 
+					p.rp-entry-metadata-line {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}
+				.rp-avatar(style="background-color: hsl({{ user.hue }}, 70%, 50%);") {{ user.avatar_text | limitTo : 1 }}
+			.rp-entry-body(ng-switch="entry.type")
+					span(ng-switch-when="insert") Added&nbsp;
+						ins.rp-content-highlight {{ entry.content }} 
+					span(ng-switch-when="delete") Deleted&nbsp;
+						del.rp-content-highlight {{ entry.content }} 
+			.rp-entry-actions
+				a.rp-entry-button(href, ng-click="onReject();") 
+					i.fa.fa-times
+					| &nbsp;Reject
+				a.rp-entry-button(href, ng-click="onAccept();") 
+					i.fa.fa-check
+					| &nbsp;Accept
+
+script(type='text/ng-template', id='commentEntryTemplate')
+	div
+		.rp-entry-callout.rp-entry-callout-comment(ng-if="!entry.resolved")
+		.rp-entry-indicator(
+			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
+			ng-click="onIndicatorClick();"
+		)
+			i.fa.fa-comment
+		.rp-entry.rp-entry-comment(
+			ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolved': entry.resolved}"
+		)
+			.rp-comment(
+				ng-if="!entry.resolved || entry.showWhenResolved"
+				ng-repeat="comment in entry.thread"
+				ng-class="users[comment.user_id].isSelf ? 'rp-comment-self' : '';"
+			)
+				.rp-avatar(
+					ng-if="!users[comment.user_id].isSelf;"
+					style="background-color: hsl({{ users[comment.user_id].hue }}, 70%, 50%);"
+				) {{ users[comment.user_id].avatar_text | limitTo : 1 }}
+				.rp-comment-body(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 90%);")
+					p.rp-comment-content {{ comment.content }}
+					p.rp-comment-metadata
+						| {{ comment.ts | date : 'MMM d, y h:mm a' }}
+						| &nbsp;&bull;&nbsp;
+						span(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 40%);") {{ users[comment.user_id].name }}
+			.rp-comment-reply(ng-if="!entry.resolved || entry.showWhenResolved")
+				textarea.rp-comment-input(
+					ng-model="entry.replyContent"
+					ng-keypress="handleCommentReplyKeyPress($event);"
+					stop-propagation="click"
+					placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}"
+				)
+			.rp-comment-resolved-description(ng-if="entry.resolved && !entry.showWhenResolved")
+				div
+					| Comment resolved by 
+					span(style="color: hsl({{ users[entry.resolved_data.user_id].hue }}, 70%, 40%);") {{ users[entry.resolved_data.user_id].name }}
+				div {{ entry.resolved_data.ts | date : 'MMM d, y h:mm a' }}
+			.rp-entry-actions
+				a.rp-entry-button(href, ng-click="onResolve();", ng-if="!entry.resolved") 
+					i.fa.fa-check
+					| &nbsp;Mark as resolved
+				a.rp-entry-button(href, ng-click="onShowThread();", ng-if="entry.resolved && !entry.showWhenResolved")
+					| &nbsp;Show
+				a.rp-entry-button(href, ng-click="onHideThread();", ng-if="entry.resolved && entry.showWhenResolved")
+					| &nbsp;Hide
+				a.rp-entry-button(href, ng-click="onUnresolve();", ng-if="entry.resolved") 
+					| &nbsp;Re-open
+				a.rp-entry-button(href, ng-click="onDelete();", ng-if="entry.resolved") 
+					| &nbsp;Delete
+				
+
+script(type='text/ng-template', id='addCommentEntryTemplate')
+	div
+		.rp-entry-callout.rp-entry-callout-add-comment
+		.rp-entry-indicator(
+			ng-if="!commentState.adding"
+			ng-click="startNewComment(); onIndicatorClick();"
+			tooltip="Add a comment"
+			tooltip-placement="right"
+			tooltip-append-to-body="true"
+		)
+			i.fa.fa-commenting
+		.rp-entry.rp-entry-add-comment(
+			ng-class="[ (state.isAdding ? 'rp-entry-adding-comment' : ''), (entry.focused ? 'rp-entry-focused' : '')]"
+		)
+			a.rp-add-comment-btn(
+				href
+				ng-if="!state.isAdding"
+				ng-click="startNewComment();"
+			) 
+				i.fa.fa-comment
+				| &nbsp;Add comment
+			div(ng-if="state.isAdding")
+				.rp-new-comment
+					textarea.rp-comment-input(
+						ng-model="state.content"
+						ng-keypress="handleCommentKeyPress($event);"
+						placeholder="Add your comment here"
+					)
+				.rp-entry-actions
+					a.rp-entry-button(href, ng-click="cancelNewComment();") 
+						i.fa.fa-times
+						| &nbsp;Cancel
+					a.rp-entry-button(href, ng-click="submitNewComment()") 
+						i.fa.fa-comment
+						| &nbsp;Comment

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

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

+ 40 - 2
services/web/app/views/project/list.jade

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

+ 9 - 44
services/web/app/views/project/list/side-bar.jade

@@ -109,51 +109,16 @@
 					) #{translate("complete")}
 
 
-		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
-			span(sixpack-default).text-centered
-				hr
-				p.small #{translate("on_free_sl")}
-				p
-					a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-				p.small.text-centered
-					| #{translate("or_unlock_features_bonus")} 
-					a(href="/user/bonus") #{translate("sharing_sl")} .
+		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak).text-centered
+			hr
+			p.small #{translate("on_free_sl")}
+			p
+				a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+			p.small.text-centered
+				| #{translate("or_unlock_features_bonus")} 
+				a(href="/user/bonus") #{translate("sharing_sl")} .
 
-			span(sixpack-when="random").text-centered
-				span(ng-if="randomView == 'default'")
-					hr
-					p.small #{translate("on_free_sl")}
-					p
-						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-					p.small.text-centered
-						| #{translate("or_unlock_features_bonus")} 
-						a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'dropbox'")
-					hr
-					.card.card-thin
-						p
-							span Get Dropbox Sync
-						p
-							img(src=buildImgPath("dropbox/simple_logo.png"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'github'")
-					hr
-					.card.card-thin
-						p
-							span Get Github Sync
-						p
-							img(src=buildImgPath("github/octocat.jpg"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
+			
 script.
 	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
 

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

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

+ 19 - 29
services/web/app/views/subscriptions/dashboard.jade

@@ -12,8 +12,8 @@ block scripts
 
 mixin printPlan(plan)
 	-if (!plan.hideFromUsers)
-		tr(ng-controller="ChangePlanFormController")
-			td(ng-init="plan=#{JSON.stringify(plan)}")
+		tr(ng-controller="ChangePlanFormController", ng-init="plan=#{JSON.stringify(plan)}", ng-show="shouldShowPlan(plan.planCode)")
+			td
 				strong #{plan.name}
 			td {{refreshPrice(plan.planCode)}}
 				-if (plan.annual)
@@ -46,8 +46,8 @@ block content
 							| &nbsp;
 							| #{translate("your_billing_details_were_saved")}
 					.card(ng-if="view == 'overview'")
-						.page-header
-							h1 #{translate("your_subscription")}
+						.page-header(x-current-plan="#{subscription.planCode}")
+							h1 #{translate("your_subscription")} 
 
 						- if (subscription && user._id+'' == subscription.admin_id+'')
 							case subscription.state
@@ -56,7 +56,8 @@ block content
 									
 								when "active"
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})} 
-										a(href, ng-click="changePlan = true") !{translate("change_plan")}.
+										span(ng-show="!isNextGenPlan")
+											a(href, ng-click="changePlan = true") !{translate("change_plan")}.
 									p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + subscription.price + "</strong>", collectionDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
 									p.pull-right
 									p
@@ -112,39 +113,28 @@ block content
 							div
 								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
 
-
-
 					.card(ng-if="view == 'cancelation'")
 						.page-header
 							h1 #{translate("Cancel Subscription")}
 
-						span(ng-if="sixpackOpt == 'downgrade-options'")
+						div(ng-show="showExtendFreeTrial", style="text-align: center")
+							p !{translate("have_more_days_to_try", {days:14})}
+							button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+							p
+								| &nbsp;
+							p
+								a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
 
-							div(ng-show="showExtendFreeTrial", style="text-align: center")
-								p !{translate("have_more_days_to_try", {days:14})}
-								button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+						div(ng-show="showDowngradeToStudent", style="text-align: center")
+							span(ng-controller="ChangePlanFormController")
+								p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
+								button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
 								p
 									| &nbsp;
 								p
-									a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
-
-
-							div(ng-show="showDowngradeToStudent", style="text-align: center")
-								span(ng-controller="ChangePlanFormController")
-									p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
-									button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
-									p
-										| &nbsp;
-									p
-										a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
-
-							div(ng-show="showBasicCancel")
-								p #{translate("sure_you_want_to_cancel")}
-								a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
-								| &nbsp;
-								a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+									a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
 
-						span(ng-if="sixpackOpt == 'basic'")
+						div(ng-show="showBasicCancel")
 							p #{translate("sure_you_want_to_cancel")}
 							a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
 							| &nbsp;

+ 136 - 125
services/web/app/views/subscriptions/plans.jade

@@ -3,6 +3,7 @@ block scripts
 	script(type='text/javascript').
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.abCurrencyFlag = '#{abCurrencyFlag}'
+		window.shouldABTestPlans = #{shouldABTestPlans || false}
 
 	script(type='text/javascript').
 		(function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; 
@@ -56,133 +57,143 @@ block content
 										ng-click="changeCurreny(currency)"
 									) {{currency}} ({{value['symbol']}})
 
-				.row(ng-cloak)
-					.col-md-10.col-md-offset-1
-						.row
-							.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
-								.col-md-4
-									.card.card-first
-										.card-header
-											h2 #{translate("personal")} 
-										.circle #{translate("free")} 
-										ul.list-unstyled
-											li #{translate("one_collaborator")}
-											li &nbsp;
-											li &nbsp;
-											li &nbsp;
-											li
-												br
-												a.btn.btn-info(
-													href="/register"
-													style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
-												) #{translate("sign_up_now")}
-								.col-md-4
-									.card.card-highlighted
-										.card-header
-											h2 #{translate("collaborator")}
-										.circle
-											span(ng-if="ui.view == 'monthly'")
-												| {{plans[currencyCode]['collaborator']['monthly']}}
-												span.small /mo
-											span(ng-if="ui.view == 'annual'")
-												| {{plans[currencyCode]['collaborator']['annual']}}
-												span.small /yr
-										ul.list-unstyled
-											li 
-												strong #{translate("collabs_per_proj", {collabcount:10})}
-											li #{translate("full_doc_history")}
-											li #{translate("sync_to_dropbox")}
-											li #{translate("sync_to_github")}
-											li
-												br
-												a.btn.btn-info(
+				div(ng-show="showPlans")
+					.row(ng-cloak)
+						.col-md-10.col-md-offset-1
+							.row
+								.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
+									.col-md-4
+										.card.card-first
+											.card-header
+												h2 #{translate("personal")} 
+											.circle #{translate("free")} 
+											ul.list-unstyled
+												li #{translate("one_collaborator")}
+												li &nbsp;
+												li &nbsp;
+												li &nbsp;
+												li
+													br
+													a.btn.btn-info(
+														href="/register"
+														style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+													) #{translate("sign_up_now")}
+									.col-md-4
+										.card.card-highlighted
+											.card-header
+												h2 #{translate("collaborator")}
+											.circle
+												span(ng-if="ui.view == 'monthly'")
+													| {{plans[currencyCode]['collaborator']['monthly']}}
+													span.small /mo
+												span(ng-if="ui.view == 'annual'")
+													| {{plans[currencyCode]['collaborator']['annual']}}
+													span.small /yr
+											ul.list-unstyled
+												li 
+													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:10})}
+													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:8})}
+													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:12})}
+												li #{translate("full_doc_history")}
+												li #{translate("sync_to_dropbox")}
+												li #{translate("sync_to_github")}
+												li
+													br
+													a.btn.btn-info(
+														ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
+													)
+														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+									.col-md-4
+										.card.card-last
+											.card-header
+												h2 #{translate("professional")}
+											.circle 
+												span(ng-if="ui.view == 'monthly'")
+													| {{plans[currencyCode]['professional']['monthly']}}
+													span.small /mo
+												span(ng-if="ui.view == 'annual'")
+													| {{plans[currencyCode]['professional']['annual']}}
+													span.small /yr
+											ul.list-unstyled
+												li
+													strong #{translate("unlimited_collabs")}
+												li #{translate("full_doc_history")}
+												li #{translate("sync_to_dropbox")}
+												li #{translate("sync_to_github")}
+												li
+													br
+													a.btn.btn-info(
+														ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
+													)
+														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+
+								.card-group.text-centered(ng-if="ui.view == 'student'")
+									.col-md-4
+										.card.card-first
+											.card-header
+												h2 #{translate("personal")}
+											.circle #{translate("free")}
+											ul.list-unstyled
+												li #{translate("one_collaborator")}
+												li &nbsp;
+												li &nbsp;
+												li &nbsp;
+												li
+													br
+													a.btn.btn-info(
+														href="/register"
+														style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+													) #{translate("sign_up_now")}
+
+									.col-md-4
+										.card.card-highlighted
+											.card-header
+												h2 #{translate("student")}
+											.circle
+												span
+													| {{plans[currencyCode]['student']['monthly']}}
+													span.small /mo
+											ul.list-unstyled
+												li
+													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
+													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
+													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
+												li #{translate("full_doc_history")}
+												li #{translate("sync_to_dropbox")}
+												li #{translate("sync_to_github")}
+												li
+													br
+													a.btn.btn-info(
+														ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}&currency={{currencyCode}}",
+														ng-click="signUpNowClicked('student')"
+													) #{translate("start_free_trial")}
+
+									.col-md-4
+										.card.card-last
+											.card-header
+												h2 #{translate("student")} (#{translate("annual")})
+											.circle
+												span
+													| {{plans[currencyCode]['student']['annual']}}
+													span.small /yr
+											ul.list-unstyled
+												li
+													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
+													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
+													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
+												li #{translate("full_doc_history")}
+												li #{translate("sync_to_dropbox")}
+												li #{translate("sync_to_github")}
+												li
+													br
+													a.btn.btn-info(
+														ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}&currency={{currencyCode}}",
+														ng-click="signUpNowClicked('student')"
+													) #{translate("buy_now")}
 
-													ng-href="#{baseUrl}/user/subscription/new?planCode=collaborator{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
-												)
-													span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
-													span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
-								.col-md-4
-									.card.card-last
-										.card-header
-											h2 #{translate("professional")}
-										.circle 
-											span(ng-if="ui.view == 'monthly'")
-												| {{plans[currencyCode]['professional']['monthly']}}
-												span.small /mo
-											span(ng-if="ui.view == 'annual'")
-												| {{plans[currencyCode]['professional']['annual']}}
-												span.small /yr
-										ul.list-unstyled
-											li
-												strong #{translate("unlimited_collabs")}
-											li #{translate("full_doc_history")}
-											li #{translate("sync_to_dropbox")}
-											li #{translate("sync_to_github")}
-											li
-												br
-												a.btn.btn-info(
-													ng-href="#{baseUrl}/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
-												)
-													span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
-													span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
 
-							.card-group.text-centered(ng-if="ui.view == 'student'")
-								.col-md-4
-									.card.card-first
-										.card-header
-											h2 #{translate("personal")}
-										.circle #{translate("free")}
-										ul.list-unstyled
-											li #{translate("one_collaborator")}
-											li &nbsp;
-											li &nbsp;
-											li &nbsp;
-											li
-												br
-												a.btn.btn-info(
-													href="/register"
-													style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
-												) #{translate("sign_up_now")}
-								
-								.col-md-4
-									.card.card-highlighted
-										.card-header
-											h2 #{translate("student")}
-										.circle
-											span
-												| {{plans[currencyCode]['student']['monthly']}}
-												span.small /mo
-										ul.list-unstyled
-											li
-												strong #{translate("collabs_per_proj", {collabcount:6})}
-											li #{translate("full_doc_history")}
-											li #{translate("sync_to_dropbox")}
-											li #{translate("sync_to_github")}
-											li
-												br
-												a.btn.btn-info(
-													ng-href="#{baseUrl}/user/subscription/new?planCode=student{{planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('student')"
-												) #{translate("start_free_trial")}
-								
-								.col-md-4
-									.card.card-last
-										.card-header
-											h2 #{translate("student")} (#{translate("annual")})
-										.circle
-											span
-												| {{plans[currencyCode]['student']['annual']}}
-												span.small /yr
-										ul.list-unstyled
-											li
-												strong #{translate("collabs_per_proj", {collabcount:6})}
-											li #{translate("full_doc_history")}
-											li #{translate("sync_to_dropbox")}
-											li #{translate("sync_to_github")}
-											li
-												br
-												a.btn.btn-info(
-													ng-href="#{baseUrl}/user/subscription/new?planCode=student-annual&currency={{currencyCode}}", ng-click="signUpNowClicked('student')"
-												) #{translate("buy_now")}
 
 				.row.row-spaced(ng-cloak)
 					p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}

+ 3 - 8
services/web/app/views/subscriptions/successful_subscription.jade

@@ -2,7 +2,7 @@ extends ../layout
 
 block content
 	.content.content-alt
-		.container
+		.container(ng-controller="SuccessfulSubscriptionController")
 			.row
 				.col-md-8.col-md-offset-2
 					.card(ng-cloak)
@@ -10,13 +10,8 @@ block content
 							h2 #{translate("thanks_for_subscribing")}
 						.alert.alert-success
 							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
-							span(sixpack-switch="upgrade-success-message")
-								span(sixpack-default)
-									p #{translate("if_you_dont_want_to_be_charged")}
-										a(href="/user/subscription") #{translate("click_here_to_cancel")}.
-								span(sixpack-when="manage-subscription")
-									p #{translate("to_modify_your_subscription_go_to")} 
-										a(href="/user/subscription")  #{translate("manage_subscription")}.
+							p #{translate("to_modify_your_subscription_go_to")} 
+								a(href="/user/subscription")  #{translate("manage_subscription")}.
 						p
 							- if (subscription.groupPlan == true)
 								a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")}

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

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

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

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

+ 52 - 21
services/web/app/views/user/settings.jade

@@ -33,25 +33,40 @@ block content
 													)
 													span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
 														|  #{translate("must_be_email_address")}
-											.form-group
-												label(for='firstName').control-label #{translate("first_name")}
-												input.form-control(
-													type='text',
-													name='first_name',
-													value=user.first_name
-												)
-											.form-group
-												label(for='lastName').control-label #{translate("last_name")}
-												input.form-control(
-													type='text',
-													name='last_name',
-													value=user.last_name
-												)
-											.actions
-												button.btn.btn-primary(
-													type='submit',
-													ng-disabled="settingsForm.$invalid"
-												)  #{translate("update")}
+											else
+												// show the email, non-editable
+												.form-group
+													label.control-label #{translate("email")}
+													div.form-control(readonly="true") #{user.email}
+
+											if shouldAllowEditingDetails
+												.form-group
+													label(for='firstName').control-label #{translate("first_name")}
+													input.form-control(
+														type='text',
+														name='first_name',
+														value=user.first_name
+													)
+												.form-group
+													label(for='lastName').control-label #{translate("last_name")}
+													input.form-control(
+														type='text',
+														name='last_name',
+														value=user.last_name
+													)
+												.actions
+													button.btn.btn-primary(
+														type='submit',
+														ng-disabled="settingsForm.$invalid"
+													)  #{translate("update")}
+											else
+												.form-group
+													label.control-label #{translate("first_name")}
+													div.form-control(readonly="true") #{user.first_name}
+												.form-group
+													label.control-label #{translate("last_name")}
+													div.form-control(readonly="true") #{user.last_name}
+
 									if !externalAuthenticationSystemUsed()
 										.col-md-5.col-md-offset-1
 											h3  #{translate("change_password")}
@@ -150,16 +165,32 @@ block content
 	script(type='text/ng-template', id='deleteAccountModalTemplate')
 		.modal-header
 			h3 #{translate("delete_account")}
-		.modal-body
-			p !{translate("delete_account_warning_message_2")}
+		div.modal-body#delete-account-modal
+			p !{translate("delete_account_warning_message_3")}
 			form(novalidate, name="deleteAccountForm")
+				label #{translate('email')}
 				input.form-control(
 					type="text",
+					autocomplete="off",
 					placeholder="",
 					ng-model="state.deleteText",
 					focus-on="open",
 					ng-keyup="checkValidation()"
 				)
+				label #{translate('password')}
+				input.form-control(
+					type="password",
+					autocomplete="off",
+					placeholder="",
+					ng-model="state.password",
+					ng-keyup="checkValidation()"
+				)
+			div(ng-if="state.error")
+				div.alert.alert-danger
+					| #{translate('generic_something_went_wrong')}
+			div(ng-if="state.invalidCredentials")
+				div.alert.alert-danger
+					| #{translate('email_or_password_wrong_try_again')}
 		.modal-footer
 			button.btn.btn-default(
 				ng-click="cancel()"

+ 40 - 24
services/web/config/settings.defaults.coffee

@@ -38,6 +38,16 @@ module.exports = settings =
 			port: "6379"
 			password: ""
 
+		# websessions:
+		# 	cluster: [
+		# 		{host: 'localhost', port: 7000}
+		# 		{host: 'localhost', port: 7001}
+		# 		{host: 'localhost', port: 7002}
+		# 		{host: 'localhost', port: 7003}
+		# 		{host: 'localhost', port: 7004}
+		# 		{host: 'localhost', port: 7005}
+		# 	]
+
 		api:
 			host: "localhost"
 			port: "6379"
@@ -107,8 +117,8 @@ module.exports = settings =
 		# references:
 		# 	url: "http://localhost:3040"
 		notifications:
-			url: "http://localhost:3042" 
-			
+			url: "http://localhost:3042"
+
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
 		showSocialButtons: false
@@ -131,13 +141,13 @@ module.exports = settings =
 
 	# this is only used if cookies are used for clsi backend
 	#clsiCookieKey: "clsiserver"
-	
+
 	# Same, but with http auth credentials.
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 
 
 	maxEntitiesPerProject: 2000
-	
+
 	# Security
 	# --------
 	security:
@@ -166,12 +176,12 @@ module.exports = settings =
 		price: 0
 		features: defaultFeatures
 	}]
-	
+
 	enableSubscriptions:false
 
 	# i18n
 	# ------
-	# 
+	#
 	i18n:
 		subdomainLang:
 			www: {lngCode:"en", url: siteUrl}
@@ -180,7 +190,7 @@ module.exports = settings =
 	# Spelling languages
 	# ------------------
 	#
-	# You must have the corresponding aspell package installed to 
+	# You must have the corresponding aspell package installed to
 	# be able to use a language.
 	languages: [
 		{name: "English", code: "en"},
@@ -228,7 +238,7 @@ module.exports = settings =
 	# analytics:
 	# 	ga:
 	# 		token: ""
-	# 
+	#
 	# ShareLaTeX's help desk is provided by tenderapp.com
 	# tenderUrl: ""
 	#
@@ -262,10 +272,14 @@ module.exports = settings =
 	# then set this to true to allow it to correctly detect the forwarded IP
 	# address and http/https protocol information.
 	behindProxy: false
-	
+
 	# Cookie max age (in milliseconds). Set to false for a browser session.
 	cookieSessionLength: 5 * 24 * 60 * 60 * 1000 # 5 days
-	
+
+	# When true, only allow invites to be sent to email addresses that
+	# already have user accounts
+	restrictInvitesToExistingAccounts: false
+
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
@@ -273,10 +287,10 @@ module.exports = settings =
 	# Use a single compile directory for all users in a project
 	# (otherwise each user has their own directory)
 	# disablePerUserCompiles: true
-	
+
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
-	
+
 	# Internal configs
 	# ----------------
 	path:
@@ -285,11 +299,11 @@ module.exports = settings =
 		# them to disk here).
 		dumpFolder: Path.resolve __dirname + "/../data/dumpFolder"
 		uploadFolder: Path.resolve __dirname + "/../data/uploads"
-	
+
 	# Automatic Snapshots
 	# -------------------
 	automaticSnapshots:
-		# How long should we wait after the user last edited to 
+		# How long should we wait after the user last edited to
 		# take a snapshot?
 		waitTimeAfterLastEdit: 5 * minutes
 		# Even if edits are still taking place, this is maximum
@@ -305,13 +319,13 @@ module.exports = settings =
 	# 	user: ""
 	# 	password: ""
 	# 	projectId: ""
-	
+
 	appName: "ShareLaTeX (Community Edition)"
 	adminEmail: "placeholder@example.com"
 
 	nav:
 		title: "ShareLaTeX Community Edition"
-		
+
 		left_footer: [{
 			text: "Powered by <a href='https://www.sharelatex.com'>ShareLaTeX</a> © 2016"
 		}]
@@ -337,6 +351,10 @@ module.exports = settings =
 			text: "Account"
 			only_when_logged_in: true
 			dropdown: [{
+				user_email: true
+			},{
+				divider: true
+			}, {
 				text: "Account Settings"
 				url: "/user/settings"
 			}, {
@@ -377,11 +395,11 @@ module.exports = settings =
 		"/templates/index": "/templates/"
 
 	proxyUrls: {}
-	
+
 	reloadModuleViewsOnEachRequest: true
 
 	domainLicences: [
-		
+
 	]
 
 	sixpack:
@@ -390,12 +408,12 @@ module.exports = settings =
 	# ----------
 
 
-	
+
 	# LDAP
 	# ----------
 	# Settings below use a working LDAP test server kindly provided by forumsys.com
 	# When testing with forumsys.com use username = einstein and password = password
-	
+
 	# ldap :
 	# 	host: 'ldap://ldap.forumsys.com'
 	# 	dn: 'uid=:userKey,dc=example,dc=com'
@@ -406,13 +424,13 @@ module.exports = settings =
 	# 	placeholder: 'email@example.com'
 	# 	emailAtt: 'mail'
 	# 	anonymous: false
-	#	adminDN: 'cn=read-only-admin,dc=example,dc=com'	
+	#	adminDN: 'cn=read-only-admin,dc=example,dc=com'
 	#	adminPW: 'password'
 	#	starttls: true
 	#	tlsOptions:
 	#		rejectUnauthorized: false
 	#		ca: ['/etc/ldap/ca_certs.pem']
-	
+
 	#templateLinks: [{
 	#	name : "CV projects",
 	#	url : "/templates/cv"
@@ -420,5 +438,3 @@ module.exports = settings =
 	#	name : "all projects",
 	#	url: "/templates/all"
 	#}]
-
-

+ 228 - 48
services/web/npm-shrinkwrap.json

@@ -387,26 +387,42 @@
       "resolved": "https://registry.npmjs.org/bufferedstream/-/bufferedstream-1.6.0.tgz"
     },
     "connect-redis": {
-      "version": "2.3.0",
-      "from": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
-      "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
+      "version": "3.1.0",
+      "from": "connect-redis@3.1.0",
+      "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-3.1.0.tgz",
       "dependencies": {
         "debug": {
-          "version": "1.0.4",
-          "from": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
+          "version": "2.3.0",
+          "from": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
           "dependencies": {
             "ms": {
-              "version": "0.6.2",
-              "from": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
-              "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
+              "version": "0.7.2",
+              "from": "ms@0.7.2"
             }
           }
         },
         "redis": {
-          "version": "0.12.1",
-          "from": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz",
-          "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
+          "version": "2.6.3",
+          "from": "https://registry.npmjs.org/redis/-/redis-2.6.3.tgz",
+          "resolved": "https://registry.npmjs.org/redis/-/redis-2.6.3.tgz",
+          "dependencies": {
+            "double-ended-queue": {
+              "version": "2.1.0-0",
+              "from": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
+              "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz"
+            },
+            "redis-commands": {
+              "version": "1.3.0",
+              "from": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz",
+              "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz"
+            },
+            "redis-parser": {
+              "version": "2.1.1",
+              "from": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.1.1.tgz",
+              "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.1.1.tgz"
+            }
+          }
         }
       }
     },
@@ -504,7 +520,7 @@
       "dependencies": {
         "cookie": {
           "version": "0.3.1",
-          "from": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
+          "from": "cookie@0.3.1",
           "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
         },
         "cookie-signature": {
@@ -864,68 +880,66 @@
       }
     },
     "express-session": {
-      "version": "1.11.3",
-      "from": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
-      "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
+      "version": "1.14.2",
+      "from": "express-session@1.14.2",
+      "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.14.2.tgz",
       "dependencies": {
         "cookie": {
-          "version": "0.1.3",
-          "from": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz",
-          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+          "version": "0.3.1",
+          "from": "cookie@0.3.1",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
         },
         "cookie-signature": {
           "version": "1.0.6",
-          "from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-          "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+          "from": "cookie-signature@1.0.6"
         },
         "crc": {
-          "version": "3.3.0",
-          "from": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz",
-          "resolved": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz"
+          "version": "3.4.1",
+          "from": "crc@3.4.1",
+          "resolved": "https://registry.npmjs.org/crc/-/crc-3.4.1.tgz"
         },
         "debug": {
           "version": "2.2.0",
-          "from": "debug@~2.2.0",
+          "from": "debug@2.2.0",
           "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
           "dependencies": {
             "ms": {
               "version": "0.7.1",
-              "from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
+              "from": "ms@0.7.1",
               "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
             }
           }
         },
         "depd": {
-          "version": "1.0.1",
-          "from": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz",
-          "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
+          "version": "1.1.0",
+          "from": "depd@1.1.0"
         },
         "on-headers": {
           "version": "1.0.1",
-          "from": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz",
-          "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz"
+          "from": "on-headers@1.0.1"
         },
         "parseurl": {
           "version": "1.3.1",
-          "from": "parseurl@~1.3.0",
-          "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+          "from": "parseurl@1.3.1"
         },
         "uid-safe": {
-          "version": "2.0.0",
-          "from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
-          "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
+          "version": "2.1.3",
+          "from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.3.tgz",
+          "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.3.tgz",
           "dependencies": {
             "base64-url": {
-              "version": "1.2.1",
-              "from": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz",
-              "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz"
+              "version": "1.3.3",
+              "from": "base64-url@1.3.3"
+            },
+            "random-bytes": {
+              "version": "1.0.0",
+              "from": "random-bytes@1.0.0"
             }
           }
         },
         "utils-merge": {
           "version": "1.0.0",
-          "from": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
-          "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
+          "from": "utils-merge@1.0.0"
         }
       }
     },
@@ -1166,6 +1180,53 @@
         }
       }
     },
+    "ioredis": {
+      "version": "2.4.0",
+      "from": "https://registry.npmjs.org/ioredis/-/ioredis-2.4.0.tgz",
+      "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-2.4.0.tgz",
+      "dependencies": {
+        "bluebird": {
+          "version": "3.4.6",
+          "from": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz",
+          "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz"
+        },
+        "cluster-key-slot": {
+          "version": "1.0.8",
+          "from": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.0.8.tgz",
+          "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.0.8.tgz"
+        },
+        "debug": {
+          "version": "2.3.0",
+          "from": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.2",
+              "from": "ms@0.7.2"
+            }
+          }
+        },
+        "double-ended-queue": {
+          "version": "2.1.0-0",
+          "from": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
+          "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz"
+        },
+        "flexbuffer": {
+          "version": "0.0.6",
+          "from": "flexbuffer@0.0.6"
+        },
+        "redis-commands": {
+          "version": "1.3.0",
+          "from": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz"
+        },
+        "redis-parser": {
+          "version": "1.3.0",
+          "from": "https://registry.npmjs.org/redis-parser/-/redis-parser-1.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-1.3.0.tgz"
+        }
+      }
+    },
     "jade": {
       "version": "1.3.1",
       "from": "https://registry.npmjs.org/jade/-/jade-1.3.1.tgz",
@@ -1783,7 +1844,7 @@
       "dependencies": {
         "debug": {
           "version": "2.2.0",
-          "from": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "from": "debug@2.2.0",
           "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
           "dependencies": {
             "ms": {
@@ -2518,33 +2579,152 @@
     },
     "passport": {
       "version": "0.3.2",
-      "from": "passport@*",
+      "from": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz",
       "resolved": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz",
       "dependencies": {
         "passport-strategy": {
           "version": "1.0.0",
-          "from": "passport-strategy@>=1.0.0 <2.0.0",
+          "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
           "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
         },
         "pause": {
           "version": "0.0.1",
-          "from": "pause@0.0.1",
+          "from": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
           "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz"
         }
       }
     },
+    "passport-ldapauth": {
+      "version": "0.6.0",
+      "from": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-0.6.0.tgz",
+      "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-0.6.0.tgz",
+      "dependencies": {
+        "passport-strategy": {
+          "version": "1.0.0",
+          "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
+        },
+        "ldapauth-fork": {
+          "version": "2.5.3",
+          "from": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-2.5.3.tgz",
+          "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-2.5.3.tgz",
+          "dependencies": {
+            "bcryptjs": {
+              "version": "2.3.0",
+              "from": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.3.0.tgz",
+              "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.3.0.tgz"
+            },
+            "lru-cache": {
+              "version": "3.2.0",
+              "from": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz",
+              "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz",
+              "dependencies": {
+                "pseudomap": {
+                  "version": "1.0.2",
+                  "from": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+                  "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "passport-local": {
       "version": "1.0.0",
-      "from": "passport-local@*",
+      "from": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
       "dependencies": {
         "passport-strategy": {
           "version": "1.0.0",
-          "from": "passport-strategy@>=1.0.0 <2.0.0",
+          "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
           "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
         }
       }
     },
+    "passport-saml": {
+      "version": "0.15.0",
+      "from": "passport-saml@",
+      "resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-0.15.0.tgz",
+      "dependencies": {
+        "passport-strategy": {
+          "version": "1.0.0",
+          "from": "passport-strategy@*"
+        },
+        "q": {
+          "version": "1.1.2",
+          "from": "q@1.1.x"
+        },
+        "xml2js": {
+          "version": "0.4.17",
+          "from": "xml2js@0.4.x",
+          "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz",
+          "dependencies": {
+            "sax": {
+              "version": "1.2.1",
+              "from": "sax@>=0.6.0"
+            },
+            "xmlbuilder": {
+              "version": "4.2.1",
+              "from": "xmlbuilder@^4.1.0",
+              "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz"
+            }
+          }
+        },
+        "xml-crypto": {
+          "version": "0.8.4",
+          "from": "xml-crypto@0.8.x",
+          "dependencies": {
+            "xmldom": {
+              "version": "0.1.19",
+              "from": "xmldom@=0.1.19"
+            },
+            "xpath.js": {
+              "version": "1.0.6",
+              "from": "xpath.js@>=0.0.3"
+            }
+          }
+        },
+        "xmldom": {
+          "version": "0.1.22",
+          "from": "xmldom@0.1.x"
+        },
+        "xmlbuilder": {
+          "version": "2.5.2",
+          "from": "xmlbuilder@2.5.x",
+          "dependencies": {
+            "lodash": {
+              "version": "3.2.0",
+              "from": "lodash@~3.2.0"
+            }
+          }
+        },
+        "xml-encryption": {
+          "version": "0.7.4",
+          "from": "xml-encryption@~0.7",
+          "dependencies": {
+            "ejs": {
+              "version": "0.8.8",
+              "from": "ejs@~0.8.3"
+            },
+            "async": {
+              "version": "0.2.10",
+              "from": "async@~0.2.7"
+            },
+            "xpath": {
+              "version": "0.0.5",
+              "from": "xpath@0.0.5",
+              "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.5.tgz"
+            },
+            "node-forge": {
+              "version": "0.2.24",
+              "from": "node-forge@0.2.24",
+              "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.2.24.tgz"
+            }
+          }
+        }
+      }
+    },
     "pg": {
       "version": "6.0.3",
       "from": "https://registry.npmjs.org/pg/-/pg-6.0.3.tgz",
@@ -3431,7 +3611,7 @@
         },
         "tough-cookie": {
           "version": "2.3.1",
-          "from": "tough-cookie@>=0.12.0",
+          "from": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.1.tgz",
           "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.1.tgz"
         },
         "tunnel-agent": {

+ 7 - 6
services/web/package.json

@@ -17,17 +17,18 @@
     "bcrypt": "0.8.5",
     "body-parser": "^1.13.1",
     "bufferedstream": "1.6.0",
-    "connect-redis": "2.3.0",
+    "connect-redis": "^3.1.0",
     "contentful": "^3.3.14",
     "cookie": "^0.2.3",
     "cookie-parser": "1.3.5",
     "csurf": "^1.8.3",
     "dateformat": "1.0.4-1.2.3",
     "express": "4.13.0",
-    "express-session": "1.11.3",
+    "express-session": "^1.14.2",
     "grunt": "^0.4.5",
     "heapdump": "^0.3.7",
     "http-proxy": "^1.8.1",
+    "ioredis": "^2.4.0",
     "jade": "~1.3.1",
     "ldapjs": "^0.7.1",
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
@@ -35,7 +36,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.3.0",
+    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.6.0",
     "mimelib": "0.2.14",
     "mocha": "1.17.1",
     "mongojs": "0.18.2",
@@ -47,9 +48,8 @@
     "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
     "passport": "^0.3.2",
+    "passport-ldapauth": "^0.6.0",
     "passport-local": "^1.0.0",
-    "pg": "^6.0.3",
-    "pg-hstore": "^2.3.2",
     "redback": "0.4.0",
     "redis": "0.10.1",
     "redis-sharelatex": "0.0.9",
@@ -63,7 +63,8 @@
     "temp": "^0.8.3",
     "underscore": "1.6.0",
     "v8-profiler": "^5.2.3",
-    "xml2js": "0.2.0"
+    "xml2js": "0.2.0",
+    "passport-saml": "^0.15.0"
   },
   "devDependencies": {
     "bunyan": "0.22.1",

+ 3 - 1
services/web/public/coffee/directives/asyncForm.coffee

@@ -24,8 +24,10 @@ define [
 
 					scope[attrs.name].inflight = true
 
+					# for asyncForm prevent automatic redirect to /login if
+					# authentication fails, we will handle it ourselves
 					$http
-						.post(element.attr('action'), formData)
+						.post(element.attr('action'), formData, {disableAutoLoginRedirect: true})
 						.success (data, status, headers, config) ->
 							scope[attrs.name].inflight = false
 							response.success = true

+ 20 - 18
services/web/public/coffee/ide.coffee

@@ -9,7 +9,9 @@ define [
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
 	"ide/references/ReferencesManager"
+	"ide/review-panel/ReviewPanelManager"
 	"ide/SafariScrollPatcher"
+	"ide/FeatureOnboardingController"
 	"ide/settings/index"
 	"ide/share/index"
 	"ide/chat/index"
@@ -41,6 +43,7 @@ define [
 	PdfManager
 	BinaryFilesManager
 	ReferencesManager
+	ReviewPanelManager
 	SafariScrollPatcher
 ) ->
 
@@ -54,6 +57,9 @@ define [
 			else
 				this.$originalApply(fn);
 
+		if window.location.search.match /tcon=true/ # track changes on
+			$scope.trackChangesFeatureFlag = true
+	
 		$scope.state = {
 			loading: true
 			load_progress: 40
@@ -64,35 +70,31 @@ define [
 			view: "editor"
 			chatOpen: false
 			pdfLayout: 'sideBySide'
+			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}") and $scope.trackChangesFeatureFlag
+			showCodeCheckerOnboarding: !window.userSettings.syntaxValidation?
 		}
 		$scope.user = window.user
+
+		$scope.shouldABTestPlans = false
+		if $scope.user.signUpDate >= '2016-10-27'
+			$scope.shouldABTestPlans = true
+
 		$scope.settings = window.userSettings
 		$scope.anonymous = window.anonymous
 
 		$scope.chat = {}
 
+		ide.toggleReviewPanel = $scope.toggleReviewPanel = () ->
+			$scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
 
-		# Only run the header AB test for newly registered users.
-		_abTestStartDate = new Date(Date.UTC(2016, 8, 28))
-		_userSignUpDate = new Date(window.user.signUpDate)
-		
-		$scope.shouldABTestHeaderLabels = _userSignUpDate > _abTestStartDate
-		$scope.headerLabelsABVariant = ""
-
-		if ($scope.shouldABTestHeaderLabels)
-			sixpack.participate "editor-header", [ "default", "labels"], (chosenVariation) ->
-				$scope.headerLabelsABVariant = chosenVariation
-
-		$scope.trackABTestConversion = (headerItem) ->
-			event_tracking.sendMB "header-ab-conversion", {
-				headerItem: headerItem,
-				variant: $scope.headerLabelsABVariant
-			}
+		$scope.$watch "ui.reviewPanelOpen", (value) ->
+			if value?
+				localStorage "ui.reviewPanelOpen.#{window.project_id}", value
 
 		# Tracking code.
 		$scope.$watch "ui.view", (newView, oldView) ->
 			if newView? and newView != "editor" and newView != "pdf"
-				event_tracking.sendMBOnce "ide-open-view-#{ newView }-once" 
+				event_tracking.sendMBOnce "ide-open-view-#{ newView }-once"
 
 		$scope.$watch "ui.chatOpen", (isOpen) ->
 			event_tracking.sendMBOnce "ide-open-chat-once" if isOpen
@@ -105,7 +107,7 @@ define [
 		# End of tracking code.
 
 		window._ide = ide
-		
+
 		ide.validFileRegex = '^[^\*\/]*$' # Don't allow * and /
 
 		ide.project_id = $scope.project_id = window.project_id

+ 35 - 0
services/web/public/coffee/ide/FeatureOnboardingController.coffee

@@ -0,0 +1,35 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FeatureOnboardingController", ($scope, settings) ->
+		$scope.innerStep = 1
+
+		$scope.turnCodeCheckOn = () ->
+			settings.saveSettings({ syntaxValidation: true })
+			$scope.settings.syntaxValidation = true
+			navToInnerStep2()
+			
+		$scope.turnCodeCheckOff = () ->
+			settings.saveSettings({ syntaxValidation: false })
+			$scope.settings.syntaxValidation = false
+			navToInnerStep2()
+
+		$scope.dismiss = () ->
+			$scope.ui.leftMenuShown = false
+			$scope.ui.showCodeCheckerOnboarding = false
+
+		navToInnerStep2 = () ->
+			$scope.innerStep = 2
+			$scope.ui.leftMenuShown = true
+
+		handleKeypress = (e) ->
+			if e.keyCode == 13
+				if $scope.innerStep == 1
+					$scope.turnCodeCheckOn()
+				else
+					$scope.dismiss()
+
+		$(document).on "keypress", handleKeypress
+
+		$scope.$on "$destroy", () -> 
+			$(document).off "keypress", handleKeypress

+ 6 - 2
services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee

@@ -1,7 +1,11 @@
 define [
 	"base"
-], (App) ->
+	"ide/colors/ColorManager"
+], (App, ColorManager) ->
 	App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
 		$scope.hue = (user) ->
-			ide.onlineUsersManager.getHueForUserId(user.id)
+			if !user?
+				return 0
+			else
+				return ColorManager.getHueForUserId(user.id)
 	]

+ 44 - 0
services/web/public/coffee/ide/colors/ColorManager.coffee

@@ -0,0 +1,44 @@
+define [], () ->
+	ColorManager =
+		getColorScheme: (hue, element) ->
+			if @isDarkTheme(element)
+				return {
+					cursor: "hsl(#{hue}, 70%, 50%)"
+					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
+					highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
+					strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
+					strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
+				}
+			else
+				return {
+					cursor: "hsl(#{hue}, 70%, 50%)"
+					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
+					highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
+					strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
+					strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
+				}
+
+		isDarkTheme: (element) ->
+			rgb = element.find(".ace_editor").css("background-color");
+			[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
+			r = parseInt(r, 10)
+			g = parseInt(g, 10)
+			b = parseInt(b, 10)
+			return r + g + b < 3 * 128
+		
+		OWN_HUE: 200 # We will always appear as this color to ourselves
+		ANONYMOUS_HUE: 100
+		getHueForUserId: (user_id) ->
+			if !user_id? or user_id == "anonymous-user"
+				return @ANONYMOUS_HUE
+
+			if window.user.id == user_id
+				return @OWN_HUE
+
+			hash = CryptoJS.MD5(user_id)
+			hue = parseInt(hash.toString().slice(0,8), 16) % 320
+			# Avoid 20 degrees either side of the personal hue
+			if hue > @OWNER_HUE - 20
+				hue = hue + 40
+			return hue
+		

+ 117 - 22
services/web/public/coffee/ide/connection/ConnectionManager.coffee

@@ -20,6 +20,12 @@ define [], () ->
 				@disconnectIfInactive()
 			, ONEHOUR)
 
+			# trigger a reconnect immediately if network comes back online
+			window.addEventListener 'online', =>
+				sl_console.log "[online] browser notified online"
+				if !@connected
+					@tryReconnectWithRateLimit({force:true})
+
 			@userIsLeavingPage = false
 			window.addEventListener 'beforeunload', =>
 				@userIsLeavingPage = true
@@ -27,33 +33,49 @@ define [], () ->
 
 			@connected = false
 			@userIsInactive = false
-			
-			@$scope.connection = 
+			@gracefullyReconnecting = false
+
+			@$scope.connection =
 				reconnecting: false
 				# If we need to force everyone to reload the editor
 				forced_disconnect: false
 				inactive_disconnect: false
 
 			@$scope.tryReconnectNow = () =>
-				@tryReconnect()
+				# user manually requested reconnection via "Try now" button
+				@tryReconnectWithRateLimit({force:true})
 
 			@$scope.$on 'cursor:editor:update', () =>
-				@lastUserAction = new Date()
+				@lastUserAction = new Date()  # time of last edit
 				if !@connected
-					@tryReconnect()
+					# user is editing, try to reconnect
+					@tryReconnectWithRateLimit()
 
 			document.querySelector('body').addEventListener 'click', (e) =>
 				if !@connected and e.target.id != 'try-reconnect-now-button'
-					@tryReconnect()
+					# user is editing, try to reconnect
+					@tryReconnectWithRateLimit()
 
 			@ide.socket = io.connect null,
 				reconnect: false
 				'connect timeout': 30 * 1000
 				"force new connection": true
 
+			# The "connect" event is the first event we get back. It only
+			# indicates that the websocket is connected, we still need to
+			# pass authentication to join a project.
+
 			@ide.socket.on "connect", () =>
 				sl_console.log "[socket.io connect] Connected"
+
+			# The next event we should get is an authentication response
+			# from the server, either "connectionAccepted" or
+			# "connectionRejected".
+
+			@ide.socket.on 'connectionAccepted', (message) =>
+				sl_console.log "[socket.io connectionAccepted] allowed to connect"
 				@connected = true
+				@gracefullyReconnecting = false
 				@ide.pushEvent("connected")
 
 				@$scope.$apply () =>
@@ -62,16 +84,26 @@ define [], () ->
 					if @$scope.state.loading
 						@$scope.state.load_progress = 70
 
+				# we have passed authentication so we can now join the project
 				setTimeout(() =>
 					@joinProject()
 				, 100)
 
+			@ide.socket.on 'connectionRejected', (err) =>
+				sl_console.log "[socket.io connectionRejected] session not valid or other connection error"
+				# we have failed authentication, usually due to an invalid session cookie
+				return @reportConnectionError(err)
+
+			# Alternatively the attempt to connect can fail completely, so
+			# we never get into the "connect" state.
+
 			@ide.socket.on "connect_failed", () =>
 				@connected = false
 				$scope.$apply () =>
-					@$scope.state.error = "Unable to connect, please view the <u><a href='http://sharelatex.tenderapp.com/help/kb/latex-editor/editor-connection-problems'>connection problems guide</a></u> to fix the issue."
-
+					@$scope.state.error = "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue."
 
+			# We can get a "disconnect" event at any point after the
+			# "connect" event.
 
 			@ide.socket.on 'disconnect', () =>
 				sl_console.log "[socket.io disconnect] Disconnected"
@@ -81,9 +113,11 @@ define [], () ->
 				@$scope.$apply () =>
 					@$scope.connection.reconnecting = false
 
-				if !$scope.connection.forced_disconnect and !@userIsInactive
+				if !$scope.connection.forced_disconnect and !@userIsInactive and !@gracefullyReconnecting
 					@startAutoReconnectCountdown()
 
+			# Site administrators can send the forceDisconnect event to all users
+
 			@ide.socket.on 'forceDisconnect', (message) =>
 				@$scope.$apply () =>
 					@$scope.permissions.write = false
@@ -97,21 +131,33 @@ define [], () ->
 				setTimeout () ->
 					location.reload()
 				, 10 * 1000
-				
+
+			@ide.socket.on "reconnectGracefully", () =>
+				sl_console.log "Reconnect gracefully"
+				@reconnectGracefully()
+
+		# Error reporting, which can reload the page if appropriate
+
+		reportConnectionError: (err) ->
+			sl_console.log "[socket.io] reporting connection error"
+			if err?.message == "not authorized" or err?.message == "invalid session"
+				window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
+			else
+				@ide.socket.disconnect()
+				@ide.showGenericMessageModal("Something went wrong connecting", """
+					Something went wrong connecting to your project. Please refresh is this continues to happen.
+				""")
+
 		joinProject: () ->
 			sl_console.log "[joinProject] joining..."
+			# Note: if the "joinProject" message doesn't reach the server
+			# (e.g. if we are in a disconnected state at this point) the
+			# callback will never be executed
 			@ide.socket.emit 'joinProject', {
 				project_id: @ide.project_id
 			}, (err, project, permissionsLevel, protocolVersion) =>
 				if err?
-					if err.message == "not authorized"
-						window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
-					else
-						@ide.socket.disconnect()
-						@ide.showGenericMessageModal("Something went wrong connecting", """
-							Something went wrong connecting to your project. Please refresh is this continues to happen.
-						""")
-					return
+					return @reportConnectionError(err)
 
 				if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
 					location.reload(true)
@@ -129,11 +175,13 @@ define [], () ->
 			@tryReconnect()
 
 		disconnect: () ->
+			sl_console.log "[socket.io] disconnecting client"
 			@ide.socket.disconnect()
 
 		startAutoReconnectCountdown: () ->
+			sl_console.log "[ConnectionManager] starting autoreconnect countdown"
 			twoMinutes = 2 * 60 * 1000
-			if @lastUpdated? and new Date() - @lastUpdated > twoMinutes
+			if @lastUserAction? and new Date() - @lastUserAction > twoMinutes
 				# between 1 minute and 3 minutes
 				countdown = 60 + Math.floor(Math.random() * 120)
 			else
@@ -152,10 +200,16 @@ define [], () ->
 			, 200)
 
 		cancelReconnect: () ->
-			clearTimeout @timeoutId if @timeoutId?
-					
+			# clear timeout and set to null so we know there is no countdown running
+			if @timeoutId?
+				sl_console.log "[ConnectionManager] cancelling existing reconnect timer"
+				clearTimeout @timeoutId
+				@timeoutId = null
+
 		decreaseCountdown: () ->
+			@timeoutId = null
 			return if !@$scope.connection.reconnection_countdown?
+			sl_console.log "[ConnectionManager] decreasing countdown", @$scope.connection.reconnection_countdown
 			@$scope.$apply () =>
 				@$scope.connection.reconnection_countdown--
 
@@ -166,13 +220,33 @@ define [], () ->
 				@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
 
 		tryReconnect: () ->
+			sl_console.log "[ConnectionManager] tryReconnect"
 			@cancelReconnect()
 			delete @$scope.connection.reconnection_countdown
 			return if @connected
 			@$scope.connection.reconnecting = true
-			@ide.socket.socket.reconnect()
+			# use socket.io connect() here to make a single attempt, the
+			# reconnect() method makes multiple attempts
+			@ide.socket.socket.connect()
+			# record the time of the last attempt to connect
+			@lastConnectionAttempt = new Date()
 			setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
 
+		MIN_RETRY_INTERVAL: 1000 # ms, rate limit on reconnects for user clicking "try now"
+		BACKGROUND_RETRY_INTERVAL : 5 * 1000 # ms, rate limit on reconnects for other user activity (e.g. cursor moves)
+
+		tryReconnectWithRateLimit: (options) ->
+			# bail out if the reconnect is already in progress
+			return if @$scope.connection?.reconnecting
+			# bail out if we are going to reconnect soon anyway
+			reconnectingSoon = @$scope.connection?.reconnection_countdown? and @$scope.connection.reconnection_countdown <= 5
+			clickedTryNow = options?.force  # user requested reconnection
+			return if reconnectingSoon and not clickedTryNow
+			# bail out if we tried reconnecting recently
+			allowedInterval = if clickedTryNow then @MIN_RETRY_INTERVAL else @BACKGROUND_RETRY_INTERVAL
+			return if @lastConnectionAttempt? and new Date() - @lastConnectionAttempt < allowedInterval
+			@tryReconnect()
+
 		disconnectIfInactive: ()->
 			@userIsInactive = (new Date() - @lastUserAction) > @disconnectAfterMs
 			if @userIsInactive and @connected
@@ -180,3 +254,24 @@ define [], () ->
 				@$scope.$apply () =>
 					@$scope.connection.inactive_disconnect = true
 
+		RECONNECT_GRACEFULLY_RETRY_INTERVAL: 5000 # ms
+		MAX_RECONNECT_GRACEFULLY_INTERVAL: 60 * 5 * 1000 # 5 minutes
+		reconnectGracefully: () ->
+			@reconnectGracefullyStarted ?= new Date()
+			userIsInactive = (new Date() - @lastUserAction) > @RECONNECT_GRACEFULLY_RETRY_INTERVAL
+			maxIntervalReached = (new Date() - @reconnectGracefullyStarted) > @MAX_RECONNECT_GRACEFULLY_INTERVAL
+			if userIsInactive or maxIntervalReached
+				sl_console.log "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
+				@_reconnectGracefullyNow()
+			else
+				sl_console.log "[reconnectGracefully] User is working, will try again in 5 seconds"
+				setTimeout () =>
+					@reconnectGracefully()
+				, @RECONNECT_GRACEFULLY_RETRY_INTERVAL
+
+		_reconnectGracefullyNow: () ->
+			@gracefullyReconnecting = true
+			@reconnectGracefullyStarted = null
+			# Clear cookie so we don't go to the same backend server
+			$.cookie("SERVERID", "", { expires: -1, path: "/" })
+			@reconnectImmediately()

+ 3 - 2
services/web/public/coffee/ide/directives/layout.coffee

@@ -94,8 +94,6 @@ define [
 					$(window).unload () ->
 						ide.localStorage("layout.#{name}", element.layout().readState())
 
-
-
 					if attrs.openEast?
 						scope.$watch attrs.openEast, (value, oldValue) ->
 							if value? and value != oldValue
@@ -107,6 +105,9 @@ define [
 								scope.$digest()
 							, 0
 
+					if attrs.allowOverflowOn?
+						element.layout().allowOverflow(scope.$eval(attrs.allowOverflowOn))
+
 					resetOpenStates()
 					onInternalResize()
 

+ 29 - 0
services/web/public/coffee/ide/editor/AceShareJsCodec.coffee

@@ -0,0 +1,29 @@
+define [], () ->
+	AceShareJsCodec =
+		aceRangeToShareJs: (range, lines) ->
+			offset = 0
+			for line, i in lines
+				offset += if i < range.row
+					line.length
+				else
+					range.column
+			offset += range.row # Include newlines
+			return offset
+		
+		aceChangeToShareJs: (delta, lines) ->
+			offset = AceShareJsCodec.aceRangeToShareJs(delta.start, lines)
+
+			text = delta.lines.join('\n')
+			switch delta.action
+				when 'insert'
+					return { i: text, p: offset }
+				when 'remove'
+					return { d: text, p: offset }
+				else throw new Error "unknown action: #{delta.action}"
+		
+		shareJsOffsetToAcePosition: (offset, lines) ->
+			row = 0
+			for line, row in lines
+				break if offset <= line.length
+				offset -= lines[row].length + 1 # + 1 for newline char
+			return {row:row, column:offset}

+ 23 - 7
services/web/public/coffee/ide/editor/Document.coffee

@@ -69,6 +69,12 @@ define [
 		getPendingOp: () ->
 			@doc?.getPendingOp()
 
+		getRecentAck: () ->
+			@doc?.getRecentAck()
+
+		getOpSize: (op) ->
+			@doc?.getOpSize(op)
+
 		hasBufferedOps: () ->
 			@doc?.hasBufferedOps()
 
@@ -143,24 +149,34 @@ define [
 		clearChaosMonkey: () ->
 			clearTimeout @_cm
 
+		MAX_PENDING_OP_SIZE: 30 # pending ops bigger than this are always considered unsaved
+
 		pollSavedStatus: () ->
 			# returns false if doc has ops waiting to be acknowledged or
 			# sent that haven't changed since the last time we checked.
 			# Otherwise returns true.
 			inflightOp = @getInflightOp()
 			pendingOp = @getPendingOp()
+			recentAck = @getRecentAck()
+			pendingOpSize = pendingOp? && @getOpSize(pendingOp)
 			if !inflightOp? and !pendingOp?
-				# there's nothing going on
+				# there's nothing going on, this is ok.
 				saved = true
 				sl_console.log "[pollSavedStatus] no inflight or pending ops"
 			else if inflightOp? and inflightOp == @oldInflightOp
 				# The same inflight op has been sitting unacked since we
-				# last checked.
+				# last checked, this is bad.
 				saved = false
 				sl_console.log "[pollSavedStatus] inflight op is same as before"
-			else
+			else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
+				# There is an op waiting to go to server but it is small and
+				# within the flushDelay, this is ok for now.
 				saved = true
-				sl_console.log "[pollSavedStatus] assuming saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
+				sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
+			else
+				# In any other situation, assume the document is unsaved.
+				saved = false
+				sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
 
 			@oldInflightOp = inflightOp
 			return saved
@@ -265,10 +281,10 @@ define [
 				@ide.pushEvent "externalUpdate",
 					doc_id: @doc_id
 				@trigger "externalUpdate", update
-			@doc.on "remoteop", () => 
+			@doc.on "remoteop", (args...) => 
 				@ide.pushEvent "remoteop",
 					doc_id: @doc_id
-				@trigger "remoteop"
+				@trigger "remoteop", args...
 			@doc.on "op:sent", (op) =>
 				@ide.pushEvent "op:sent",
 					doc_id: @doc_id
@@ -294,7 +310,7 @@ define [
 
 		_onError: (error, meta = {}) ->
 			meta.doc_id = @doc_id
-			console.error "ShareJS error", error, meta
+			sl_console.log "ShareJS error", error, meta
 			ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
 			@doc?.clearInflightAndPendingOps()
 			@trigger "error", error, meta

+ 15 - 3
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -8,11 +8,12 @@ define [
 			@$scope.editor = {
 				sharejs_doc: null
 				open_doc_id: null
+				open_doc_name: null
 				opening: true
 			}
 
 			@$scope.$on "entity:selected", (event, entity) =>
-				if (@$scope.ui.view != "track-changes" and entity.type == "doc")
+				if (@$scope.ui.view != "history" and entity.type == "doc")
 					@openDoc(entity)
 
 			@$scope.$on "entity:deleted", (event, entity) =>
@@ -40,6 +41,11 @@ define [
 			return if !doc?
 			@openDoc(doc)
 
+		openDocId: (doc_id, options = {}) ->
+			doc = @ide.fileTreeManager.findEntityById(doc_id)
+			return if !doc?
+			@openDoc(doc, options)
+
 		openDoc: (doc, options = {}) ->
 			sl_console.log "[openDoc] Opening #{doc.id}"
 			@$scope.ui.view = "editor"
@@ -51,7 +57,12 @@ define [
 					# CursorPositionManager
 					setTimeout () =>
 						@$scope.$broadcast "editor:gotoLine", options.gotoLine, options.gotoColumn
-					,0
+					, 0
+				else if options.gotoOffset?
+					setTimeout () =>
+						@$scope.$broadcast "editor:gotoOffset", options.gotoOffset
+					, 0
+					
 
 			if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
 				@$scope.$apply () =>
@@ -59,6 +70,7 @@ define [
 				return
 
 			@$scope.editor.open_doc_id = doc.id
+			@$scope.editor.open_doc_name = doc.name
 
 			@ide.localStorage "doc.open_id.#{@$scope.project_id}", doc.id
 			@ide.fileTreeManager.selectEntity(doc)
@@ -107,7 +119,7 @@ define [
 					@ide.reportError(error, meta)
 					@ide.showGenericMessageModal(
 						"Out of sync"
-						"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='http://sharelatex.tenderapp.com/help/kb/browsers/editor-out-of-sync-problems'>Please see this help guide for more information</a>"
+						"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>"
 					)
 				@openDoc(doc, forceReopen: true)
 

+ 17 - 2
services/web/public/coffee/ide/editor/ShareJsDoc.coffee

@@ -46,12 +46,13 @@ define [
 			@_doc.on "change", () =>
 				@trigger "change"
 			@_doc.on "acknowledge", () =>
+				@lastAcked = new Date() # note time of last ack from server for an op we sent
 				@trigger "acknowledge"
-			@_doc.on "remoteop", () =>
+			@_doc.on "remoteop", (args...) =>
 				# As soon as we're working with a collaborator, start sending
 				# ops as quickly as possible for low latency.
 				@_doc.setFlushDelay(0)
-				@trigger "remoteop"
+				@trigger "remoteop", args...
 			@_doc.on "error", (e) =>
 				@_handleError(e)
 
@@ -101,12 +102,26 @@ define [
 			@connection.id = @socket.socket.sessionid
 			@_doc.autoOpen = false
 			@_doc._connectionStateChanged(state)
+			@lastAcked = null # reset the last ack time when connection changes
 
 		hasBufferedOps: () ->
 			@_doc.inflightOp? or @_doc.pendingOp?
 
 		getInflightOp: () -> @_doc.inflightOp
 		getPendingOp: () -> @_doc.pendingOp
+		getRecentAck: () ->
+			# check if we have received an ack recently (within the flush delay)
+			@lastAcked? and new Date() - @lastAcked < @_doc._flushDelay
+		getOpSize: (op) ->
+			# compute size of an op from its components
+			# (total number of characters inserted and deleted)
+			size = 0
+			for component in op or []
+				if component?.i?
+					size += component.i.length
+				if component?.d?
+					size += component.d.length
+			return size
 
 		attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength)
 		detachFromAce: () -> @_doc.detach_ace?()

+ 18 - 1
services/web/public/coffee/ide/editor/controllers/SavingNotificationController.coffee

@@ -10,12 +10,16 @@ define [
 		$(window).bind 'beforeunload', () =>
 			warnAboutUnsavedChanges()
 
+		lockEditorModal = null # modal showing "connection lost"
+		MAX_UNSAVED_SECONDS = 15 # lock the editor after this time if unsaved
+
 		$scope.docSavingStatus = {}
 		pollSavedStatus = () ->
 			oldStatus = $scope.docSavingStatus
 			oldUnsavedCount = $scope.docSavingStatusCount
 			newStatus = {}
 			newUnsavedCount = 0
+			maxUnsavedSeconds = 0
 
 			for doc_id, doc of Document.openDocs
 				saving = doc.pollSavedStatus()
@@ -23,13 +27,26 @@ define [
 					newUnsavedCount++
 					if oldStatus[doc_id]?
 						newStatus[doc_id] = oldStatus[doc_id]
-						newStatus[doc_id].unsavedSeconds += 1
+						t = newStatus[doc_id].unsavedSeconds += 1
+						if t > maxUnsavedSeconds
+							maxUnsavedSeconds = t
 					else
 						newStatus[doc_id] = {
 							unsavedSeconds: 0
 							doc: ide.fileTreeManager.findEntityById(doc_id)
 						}
 
+			if newUnsavedCount > 0 and t > MAX_UNSAVED_SECONDS and not lockEditorModal
+				lockEditorModal = ide.showLockEditorMessageModal(
+						"Connection lost"
+						"Sorry, the connection to the server is down."
+					)
+				lockEditorModal.result.finally () ->
+					lockEditorModal = null # unset the modal if connection comes back
+
+			if lockEditorModal and newUnsavedCount is 0
+				lockEditorModal.dismiss "connection back up"
+
 			# for performance, only update the display if the old or new
 			# counts of unsaved files are nonzeror.  If both old and new
 			# unsaved counts are zero then we know we are in a good state

+ 93 - 17
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -2,20 +2,26 @@ define [
 	"base"
 	"ace/ace"
 	"ace/ext-searchbox"
+	"ace/ext-modelist"
 	"ide/editor/directives/aceEditor/undo/UndoManager"
 	"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
 	"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
 	"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
-], (App, Ace, SearchBox, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager) ->
+], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
+	ModeList = ace.require('ace/ext/modelist')
 
 	# set the path for ace workers if using a CDN (from editor.jade)
 	if window.aceWorkerPath != ""
+		syntaxValidationEnabled = true
 		ace.config.set('workerPath', "#{window.aceWorkerPath}")
 	else
-		ace.config.setDefaultValue("session", "useWorker", false)
+		syntaxValidationEnabled = false
+
+	# By default, don't use workers - enable them per-session as required
+	ace.config.setDefaultValue("session", "useWorker", false)
 
 	# Ace loads its script itself, so we need to hook in to be able to clear
 	# the cache.
@@ -42,9 +48,16 @@ define [
 				text: "="
 				readOnly: "="
 				annotations: "="
-				navigateHighlights: "=",
+				navigateHighlights: "="
+				fileName: "="
 				onCtrlEnter: "="
 				syntaxValidation: "="
+				reviewPanel: "="
+				eventsBridge: "="
+				trackNewChanges: "="
+				trackChangesEnabled: "="
+				changesTracker: "="
+				docId: "="
 			}
 			link: (scope, element, attrs) ->
 				# Don't freak out if we're already in an apply callback
@@ -58,6 +71,11 @@ define [
 
 				editor = ace.edit(element.find(".ace-editor-body")[0])
 				editor.$blockScrolling = Infinity
+
+				# disable auto insertion of brackets and quotes
+				editor.setOption('behavioursEnabled', false)
+				editor.setOption('wrapBehavioursEnabled', false)
+
 				window.editors ||= []
 				window.editors.push editor
 
@@ -71,8 +89,6 @@ define [
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
 				trackChangesManager   = new TrackChangesManager(scope, editor, element)
-				if window.location.search.match /tcon=true/ # track changes on
-					trackChangesManager.enabled = true
 
 				# Prevert Ctrl|Cmd-S from triggering save dialog
 				editor.commands.addCommand
@@ -194,52 +210,112 @@ define [
 					editor.setReadOnly !!value
 
 				scope.$watch "syntaxValidation", (value) ->
-					session = editor.getSession()
-					session.setOption("useWorker", value);
+					# ignore undefined settings here
+					# only instances of ace with an explicit value should set useWorker
+					# the history instance will have syntaxValidation undefined
+					if value? and syntaxValidationEnabled
+						session = editor.getSession()
+						session.setOption("useWorker", value);
 
 				editor.setOption("scrollPastEnd", true)
 
-				resetSession = () ->
-					session = editor.getSession()
-					session.setUseWrapMode(true)
-					session.setMode("ace/mode/latex")
-
 				updateCount = 0
 				onChange = () ->
 					updateCount++
 					if updateCount == 100
 						event_tracking.send 'editor-interaction', 'multi-doc-update'
 					scope.$emit "#{scope.name}:change"
+				
+				onScroll = (scrollTop) ->
+					return if !scope.eventsBridge?
+					height = editor.renderer.layerConfig.maxHeight
+					scope.eventsBridge.emit "aceScroll", scrollTop, height
+
+				onScrollbarVisibilityChanged = (event, vRenderer) ->
+					return if !scope.eventsBridge?
+					scope.eventsBridge.emit "aceScrollbarVisibilityChanged", vRenderer.scrollBarV.isVisible, vRenderer.scrollBarV.width
+					
+				if scope.eventsBridge?
+					editor.renderer.on "scrollbarVisibilityChanged", onScrollbarVisibilityChanged
+
+					scope.eventsBridge.on "externalScroll", (position) ->
+						editor.getSession().setScrollTop(position)
+					scope.eventsBridge.on "refreshScrollPosition", () ->
+						session = editor.getSession()
+						session.setScrollTop(session.getScrollTop() + 1)
+						session.setScrollTop(session.getScrollTop() - 1)
 
 				attachToAce = (sharejs_doc) ->
 					lines = sharejs_doc.getSnapshot().split("\n")
 					session = editor.getSession()
 					if session?
 						session.destroy()
-					editor.setSession(new EditSession(lines, "ace/mode/latex"))
-					resetSession()
-					session = editor.getSession()
+
+					# see if we can lookup a suitable mode from ace
+					# but fall back to text by default
+					try
+						if scope.fileName.match(/\.(Rtex|bbl)$/i)
+							# recognise Rtex and bbl as latex
+							mode = "ace/mode/latex"
+						else if scope.fileName.match(/\.(sty|cls|clo)$/)
+							# recognise some common files as tex
+							mode = "ace/mode/tex"
+						else
+							mode = ModeList.getModeForPath(scope.fileName).mode
+							# we prefer plain_text mode over text mode because ace's
+							# text mode is actually for code and has unwanted
+							# indenting (see wrapMethod in ace edit_session.js)
+							if mode is "ace/mode/text"
+								mode = "ace/mode/plain_text"
+					catch
+						mode = "ace/mode/plain_text"
+
+					# Give beta users the next release of the syntax checker
+					if mode is "ace/mode/latex" and window.user?.betaProgram
+						mode = "ace/mode/latex_beta"
+
+					# create our new session
+					session = new EditSession(lines, mode)
+
+					session.setUseWrapMode(true)
+					# use syntax validation only when explicitly set
+					if scope.syntaxValidation? and syntaxValidationEnabled
+						session.setOption("useWorker", scope.syntaxValidation);
+
+					# now attach session to editor
+					editor.setSession(session)
 
 					doc = session.getDocument()
 					doc.on "change", onChange
 
-					sharejs_doc.on "remoteop.recordForUndo", () =>
+					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
+
 					# need to set annotations after attaching because attaching
 					# deletes and then inserts document content
 					session.setAnnotations scope.annotations
 
+					if scope.eventsBridge?
+						session.on "changeScrollTop", onScroll
+
+					setTimeout () ->
+						# Let any listeners init themselves
+						onScroll(editor.renderer.getScrollTop())
+
 					editor.focus()
 
 				detachFromAce = (sharejs_doc) ->
 					sharejs_doc.detachFromAce()
-					sharejs_doc.off "remoteop.recordForUndo"
+					sharejs_doc.off "remoteop.recordRemote"
 
 					session = editor.getSession()
+					session.off "changeScrollTop"
+					
 					doc = session.getDocument()
 					doc.off "change", onChange
 

+ 15 - 2
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee

@@ -1,4 +1,6 @@
-define [], () ->
+define [
+	"ide/editor/AceShareJsCodec"
+], (AceShareJsCodec) ->
 	class CursorPositionManager
 		constructor: (@$scope, @editor, @element, @localStorage) ->
 
@@ -23,11 +25,17 @@ define [], () ->
 				@storeCursorPosition(@editor.getSession())
 				@storeScrollTopPosition(@editor.getSession())
 
-			@$scope.$on "#{@$scope.name}:gotoLine", (editor, line, column) =>
+			@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
 				if line?
 					setTimeout () =>
 						@gotoLine(line, column)
 					, 10 # Hack: Must happen after @gotoStoredPosition
+			
+			@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
+				if offset?
+					setTimeout () =>
+						@gotoOffset(offset)
+					, 10 # Hack: Must happen after @gotoStoredPosition
 
 		storeScrollTopPosition: (session) ->
 			if @doc_id?
@@ -57,3 +65,8 @@ define [], () ->
 			@editor.gotoLine(line, column)
 			@editor.scrollToLine(line,true,true) # centre and animate
 			@editor.focus()
+
+		gotoOffset: (offset) ->
+			lines = @editor.getSession().getDocument().getAllLines()
+			position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
+			@gotoLine(position.row + 1, position.column)

+ 3 - 28
services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee

@@ -1,6 +1,7 @@
 define [
 	"ace/ace"
-], () ->
+	"ide/colors/ColorManager"
+], (_, ColorManager) ->
 	Range = ace.require("ace/range").Range
 
 	class HighlightsManager
@@ -64,7 +65,7 @@ define [
 
 			for annotation in @$scope.highlights or []
 				do (annotation) =>
-					colorScheme = @_getColorScheme(annotation.hue)
+					colorScheme = ColorManager.getColorScheme(annotation.hue, @element)
 					if annotation.cursor?
 						@labels.push {
 							text: annotation.label
@@ -262,29 +263,3 @@ define [
 				else
 					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
 			, foreground
-
-		_getColorScheme: (hue) ->
-			if @_isDarkTheme()
-				return {
-					cursor: "hsl(#{hue}, 70%, 50%)"
-					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
-					highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
-					strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
-					strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
-				}
-			else
-				return {
-					cursor: "hsl(#{hue}, 70%, 50%)"
-					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
-					highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
-					strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
-					strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
-				}
-
-		_isDarkTheme: () ->
-			rgb = @element.find(".ace_editor").css("background-color");
-			[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
-			r = parseInt(r, 10)
-			g = parseInt(g, 10)
-			b = parseInt(b, 10)
-			return r + g + b < 3 * 128

+ 358 - 334
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -1,38 +1,230 @@
 define [
 	"ace/ace"
 	"utils/EventEmitter"
-], (_, EventEmitter) ->
+	"ide/colors/ColorManager"
+	"ide/editor/AceShareJsCodec"
+], (_, EventEmitter, ColorManager, AceShareJsCodec) ->
 	class TrackChangesManager
 		Range = ace.require("ace/range").Range
 		
 		constructor: (@$scope, @editor, @element) ->
-			@changesTracker = new ChangesTracker()
+			window.trackChangesManager ?= @
+
+			@$scope.$watch "changesTracker", (changesTracker) =>
+				return if !changesTracker?
+				@disconnectFromChangesTracker()
+				@changesTracker = changesTracker
+				@connectToChangesTracker()
+
+			@$scope.$watch "trackNewChanges", (track_new_changes) =>
+				return if !track_new_changes?
+				@changesTracker?.track_changes = track_new_changes
+			
+			@$scope.$on "comment:add", (e, comment) =>
+				@addCommentToSelection(comment)
+
+			@$scope.$on "comment:select_line", (e) =>
+				@selectLineIfNoSelection()
+			
+			@$scope.$on "change:accept", (e, change_id) =>
+				@acceptChangeId(change_id)
+			
+			@$scope.$on "change:reject", (e, change_id) =>
+				@rejectChangeId(change_id)
+			
+			@$scope.$on "comment:remove", (e, comment_id) =>
+				@removeCommentId(comment_id)
+			
+			@$scope.$on "comment:resolve", (e, comment_id, user_id) =>
+				@resolveCommentId(comment_id, user_id)
+			
+			@$scope.$on "comment:unresolve", (e, comment_id) =>
+				@unresolveCommentId(comment_id)
+			
+			@$scope.$on "review-panel:recalculate-screen-positions", () =>
+				@recalculateReviewEntriesScreenPositions()
+
+			changingSelection = false
+			onChangeSelection = (args...) =>
+				# Deletes can send about 5 changeSelection events, so
+				# just act on the last one.
+				if !changingSelection
+					changingSelection = true
+					@$scope.$evalAsync () =>
+						changingSelection = false
+						@updateFocus()
+						@recalculateReviewEntriesScreenPositions()
+			
+			onResize = () =>
+				@recalculateReviewEntriesScreenPositions()
+
+			onChange = (e) =>
+				if !@editor.initing
+					# This change is trigger by a sharejs 'change' event, which is before the
+					# sharejs 'remoteop' event. So wait until the next event loop when the 'remoteop'
+					# will have fired, before we decide if it was a remote op.
+					setTimeout () =>
+						if @nextUpdateMetaData?
+							user_id = @nextUpdateMetaData.user_id
+							# The remote op may have contained multiple atomic ops, each of which is an Ace
+							# 'change' event (i.e. bulk commenting out of lines is a single remote op
+							# but gives us one event for each % inserted). These all come in a single event loop
+							# though, so wait until the next one before clearing the metadata.
+							setTimeout () =>
+								@nextUpdateMetaData = null
+						else
+							user_id = window.user.id
+						
+						was_tracking = @changesTracker.track_changes
+						if @dont_track_next_update
+							@changesTracker.track_changes = false
+							@dont_track_next_update = false
+						@applyChange(e, { user_id })
+						@changesTracker.track_changes = was_tracking
+						
+						# TODO: Just for debugging, remove before going live.
+						setTimeout () =>
+							@checkMapping()
+						, 100
+
+			onChangeSession = (e) =>
+				e.oldSession?.getDocument().off "change", onChange
+				e.session.getDocument().on "change", onChange
+				@redrawAnnotations()
+
+			bindToAce = () =>
+				@editor.getSession().getDocument().on "change", onChange
+				@editor.on "changeSelection", onChangeSelection
+				@editor.on "changeSession", onChangeSession
+				@editor.renderer.on "resize", onResize
+
+			unbindFromAce = () =>
+				@editor.getSession().getDocument().off "change", onChange
+				@editor.off "changeSelection", onChangeSelection
+				@editor.off "changeSession", onChangeSession
+				@editor.renderer.off "resize", onResize
+
+			@$scope.$watch "trackChangesEnabled", (enabled) =>
+				return if !enabled?
+				if enabled
+					bindToAce()
+				else
+					unbindFromAce()
+		
+		disconnectFromChangesTracker: () ->
 			@changeIdToMarkerIdMap = {}
-			@enabled = false
 
+			if @changesTracker?
+				@changesTracker.off "insert:added"
+				@changesTracker.off "insert:removed"
+				@changesTracker.off "delete:added"
+				@changesTracker.off "delete:removed"
+				@changesTracker.off "changes:moved"
+				@changesTracker.off "comment:added"
+				@changesTracker.off "comment:moved"
+				@changesTracker.off "comment:removed"
+				@changesTracker.off "comment:resolved"
+				@changesTracker.off "comment:unresolved"
+		
+		connectToChangesTracker: () ->
+			@changesTracker.track_changes = @$scope.trackNewChanges
+			
 			@changesTracker.on "insert:added", (change) =>
+				sl_console.log "[insert:added]", change
 				@_onInsertAdded(change)
 			@changesTracker.on "insert:removed", (change) =>
+				sl_console.log "[insert:removed]", change
 				@_onInsertRemoved(change)
 			@changesTracker.on "delete:added", (change) =>
+				sl_console.log "[delete:added]", change
 				@_onDeleteAdded(change)
 			@changesTracker.on "delete:removed", (change) =>
+				sl_console.log "[delete:removed]", change
 				@_onDeleteRemoved(change)
 			@changesTracker.on "changes:moved", (changes) =>
+				sl_console.log "[changes:moved]", changes
 				@_onChangesMoved(changes)
 
-			onChange = (e) =>
-				if !@editor.initing and @enabled
-					@applyChange(e)
-					setTimeout () =>
-						@checkMapping()
-					, 100
+			@changesTracker.on "comment:added", (comment) =>
+				sl_console.log "[comment:added]", comment
+				@_onCommentAdded(comment)
+			@changesTracker.on "comment:moved", (comment) =>
+				sl_console.log "[comment:moved]", comment
+				@_onCommentMoved(comment)
+			@changesTracker.on "comment:removed", (comment) =>
+				sl_console.log "[comment:removed]", comment
+				@_onCommentRemoved(comment)
+			@changesTracker.on "comment:resolved", (comment) =>
+				sl_console.log "[comment:resolved]", comment
+				@_onCommentRemoved(comment)
+			@changesTracker.on "comment:unresolved", (comment) =>
+				sl_console.log "[comment:unresolved]", comment
+				@_onCommentAdded(comment)
+			
+		redrawAnnotations: () ->
+			for change in @changesTracker.changes
+				if change.op.i?
+					@_onInsertAdded(change)
+				else if change.op.d?
+					@_onDeleteAdded(change)
 
-			@editor.on "changeSession", (e) =>
-				e.oldSession?.getDocument().off "change", onChange
-				e.session.getDocument().on "change", onChange
-			@editor.getSession().getDocument().on "change", onChange
+			for comment in @changesTracker.comments
+				@_onCommentAdded(comment)
+
+		addComment: (offset, length, content) ->
+			@changesTracker.addComment offset, length, {
+				thread: [{
+					content: content
+					user_id: window.user_id
+					ts: new Date()
+				}]
+			}
+		
+		addCommentToSelection: (content) ->
+			range = @editor.getSelectionRange()
+			offset = @_aceRangeToShareJs(range.start)
+			end = @_aceRangeToShareJs(range.end)
+			length = end - offset
+			@addComment(offset, length, content)
+		
+		selectLineIfNoSelection: () ->
+			if @editor.selection.isEmpty()
+				@editor.selection.selectLine()
 		
+		acceptChangeId: (change_id) ->
+			@changesTracker.removeChangeId(change_id)
+		
+		rejectChangeId: (change_id) ->
+			change = @changesTracker.getChange(change_id)
+			return if !change?
+			@changesTracker.removeChangeId(change_id)
+			@dont_track_next_update = true
+			session = @editor.getSession()
+			if change.op.d?
+				content = change.op.d
+				position = @_shareJsOffsetToAcePosition(change.op.p)
+				session.insert(position, content)
+			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.remove({start, end})
+			else
+				throw new Error("unknown change: #{JSON.stringify(change)}")
+
+		removeCommentId: (comment_id) ->
+			@changesTracker.removeCommentId(comment_id)
+
+		resolveCommentId: (comment_id, user_id) ->
+			@changesTracker.resolveCommentId(comment_id, {
+				user_id, ts: new Date()
+			})
+			
+		unresolveCommentId: (comment_id) ->
+			@changesTracker.unresolveCommentId(comment_id)
+
 		checkMapping: () ->
 			session = @editor.getSession()
 
@@ -41,16 +233,28 @@ define [
 			for marker_id, marker of session.getMarkers()
 				markers[marker_id] = marker
 
+			expected_markers = []
 			for change in @changesTracker.changes
-				op = change.op
-				marker_id = @changeIdToMarkerIdMap[change.id]
-				
-				start = @_shareJsOffsetToAcePosition(op.p)
-				if op.i?
-					end = @_shareJsOffsetToAcePosition(op.p + op.i.length)
-				else if op.d?
-					end = start
-					
+				if @changeIdToMarkerIdMap[change.id]?
+					op = change.op
+					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
+					start = @_shareJsOffsetToAcePosition(op.p)
+					if op.i?
+						end = @_shareJsOffsetToAcePosition(op.p + op.i.length)
+					else if op.d?
+						end = start
+					expected_markers.push { marker_id: background_marker_id, start, end }
+					expected_markers.push { marker_id: callout_marker_id, start, end: start }
+			
+			for comment in @changesTracker.comments
+				if @changeIdToMarkerIdMap[comment.id]?
+					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
+					start = @_shareJsOffsetToAcePosition(comment.offset)
+					end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+					expected_markers.push { marker_id: background_marker_id, start, end }
+					expected_markers.push { marker_id: callout_marker_id, start, end: start }
+			
+			for {marker_id, start, end} in expected_markers
 				marker = markers[marker_id]
 				delete markers[marker_id]
 				if marker.range.start.row != start.row or
@@ -63,348 +267,168 @@ define [
 				if marker.clazz.match("track-changes")
 					console.error "Orphaned ace marker", marker
 		
-		applyChange: (delta) ->
+		applyChange: (delta, metadata) ->
 			op = @_aceChangeToShareJs(delta)
-			console.log "Applying change", delta, op
-			@changesTracker.applyOp(op)
+			@changesTracker.applyOp(op, metadata)
+		
+		updateFocus: () ->
+			selection = @editor.getSelectionRange()
+			cursor_offset = @_aceRangeToShareJs(selection.start)
+			entries = @_getCurrentDocEntries()
+			selection = !(selection.start.column == selection.end.column and selection.start.row == selection.end.row)
+			@$scope.$emit "editor:focus:changed", cursor_offset, selection
+		
+		broadcastChange: () ->
+			@$scope.$emit "editor:track-changes:changed", @$scope.docId
 		
+		recalculateReviewEntriesScreenPositions: () ->
+			session = @editor.getSession()
+			renderer = @editor.renderer
+			entries = @_getCurrentDocEntries()
+			for entry_id, entry of entries or {}
+				doc_position = @_shareJsOffsetToAcePosition(entry.offset)
+				screen_position = session.documentToScreenPosition(doc_position.row, doc_position.column)
+				y = screen_position.row * renderer.lineHeight
+				entry.screenPos ?= {}
+				entry.screenPos.y = y
+				entry.docPos = doc_position
+
+			@$scope.$apply()
+	
+		_getCurrentDocEntries: () ->
+			doc_id = @$scope.docId
+			entries = @$scope.reviewPanel.entries[doc_id] ?= {}
+			return entries
+
+		_makeZeroWidthRange: (position) ->
+			ace_range = new Range(position.row, position.column, position.row, position.column)
+			# Our delete marker is zero characters wide, but Ace doesn't draw ranges
+			# that are empty. So we monkey patch the range to tell Ace it's not empty.
+			# We do want to claim to be empty if we're off screen after clipping rows though.
+			# This is the code we need to trick:
+			#   var range = marker.range.clipRows(config.firstRow, config.lastRow);
+			#   if (range.isEmpty()) continue;
+			ace_range.clipRows = (first_row, last_row) ->
+				@isEmpty = () ->
+					first_row > @end.row or last_row < @start.row
+				return @
+			return ace_range
+		
+		_createCalloutMarker: (position, klass) ->
+			session = @editor.getSession()
+			callout_range = @_makeZeroWidthRange(position)
+			markerLayer = @editor.renderer.$markerBack
+			callout_marker_id = session.addMarker callout_range, klass, (html, range, left, top, config) ->
+				markerLayer.drawSingleLineMarker(html, range, "track-changes-marker-callout #{klass} ace_start", config, 0, "width: auto; right: 0;")
+
 		_onInsertAdded: (change) ->
 			start = @_shareJsOffsetToAcePosition(change.op.p)
 			end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
 			session = @editor.getSession()
 			doc = session.getDocument()
-			ace_range = new Range(start.row, start.column, end.row, end.column)
-			marker_id = session.addMarker(ace_range, "track-changes-added-marker", "text")
-			@changeIdToMarkerIdMap[change.id] = marker_id
-		
+			background_range = new Range(start.row, start.column, end.row, end.column)
+			background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text"
+			callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout")
+			@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
+			@broadcastChange()
+
 		_onDeleteAdded: (change) ->
 			position = @_shareJsOffsetToAcePosition(change.op.p)
 			session = @editor.getSession()
 			doc = session.getDocument()
-			ace_range = new Range(position.row, position.column, position.row, position.column)
-			
-			# Our delete marker is zero characters wide, but Ace doesn't draw ranges
-			# that are empty. So we monkey patch the range to tell Ace it's not empty.
-			# This is the code we need to trick:
-			#   var range = marker.range.clipRows(config.firstRow, config.lastRow);
-			#   if (range.isEmpty()) continue;
-			_clipRows = ace_range.clipRows
-			ace_range.clipRows = (args...) ->
-				range = _clipRows.apply(ace_range, args)
-				range.isEmpty = () ->
-					false
-				return range
 
-			marker_id = session.addMarker(ace_range, "track-changes-deleted-marker", "text")
-			@changeIdToMarkerIdMap[change.id] = marker_id
+			markerLayer = @editor.renderer.$markerBack
+			klass = "track-changes-marker track-changes-deleted-marker"
+			background_range = @_makeZeroWidthRange(position)
+			background_marker_id = session.addMarker background_range, klass, (html, range, left, top, config) ->
+				markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, "")
+
+			callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout")
+			@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
+			@broadcastChange()
 		
 		_onInsertRemoved: (change) ->
-			marker_id = @changeIdToMarkerIdMap[change.id]
+			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
+			delete @changeIdToMarkerIdMap[change.id]
 			session = @editor.getSession()
-			session.removeMarker marker_id
+			session.removeMarker background_marker_id
+			session.removeMarker callout_marker_id
+			@broadcastChange()
 		
 		_onDeleteRemoved: (change) ->
-			marker_id = @changeIdToMarkerIdMap[change.id]
+			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
+			delete @changeIdToMarkerIdMap[change.id]
 			session = @editor.getSession()
-			session.removeMarker marker_id
+			session.removeMarker background_marker_id
+			session.removeMarker callout_marker_id
+			@broadcastChange()
 		
-		_aceChangeToShareJs: (delta) ->
-			start = delta.start
-			lines = @editor.getSession().getDocument().getLines 0, start.row
-			offset = 0
-			for line, i in lines
-				offset += if i < start.row
-					line.length
-				else
-					start.column
-			offset += start.row # Include newlines
+		_onCommentAdded: (comment) ->
+			if !@changeIdToMarkerIdMap[comment.id]?
+				# Only create new markers if they don't already exist
+				start = @_shareJsOffsetToAcePosition(comment.offset)
+				end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+				session = @editor.getSession()
+				doc = session.getDocument()
+				background_range = new Range(start.row, start.column, end.row, end.column)
+				background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text"
+				callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout")
+				@changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id }
+			@broadcastChange()
+		
+		_onCommentRemoved: (comment) ->
+			if @changeIdToMarkerIdMap[comment.id]?
+				# Resolved comments may not have marker ids
+				{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
+				delete @changeIdToMarkerIdMap[comment.id]
+				session = @editor.getSession()
+				session.removeMarker background_marker_id
+				session.removeMarker callout_marker_id
+			@broadcastChange()
 
-			text = delta.lines.join('\n')
-			switch delta.action
-				when 'insert'
-					return { i: text, p: offset }
-				when 'remove'
-					return { d: text, p: offset }
-				else throw new Error "unknown action: #{delta.action}"
+		_aceRangeToShareJs: (range) ->
+			lines = @editor.getSession().getDocument().getLines 0, range.row
+			return AceShareJsCodec.aceRangeToShareJs(range, lines)
+
+		_aceChangeToShareJs: (delta) ->
+			lines = @editor.getSession().getDocument().getLines 0, delta.start.row
+			return AceShareJsCodec.aceChangeToShareJs(delta, lines)
 		
 		_shareJsOffsetToAcePosition: (offset) ->
 			lines = @editor.getSession().getDocument().getAllLines()
-			row = 0
-			for line, row in lines
-				break if offset <= line.length
-				offset -= lines[row].length + 1 # + 1 for newline char
-			return {row:row, column:offset}
+			return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
 		
 		_onChangesMoved: (changes) ->
-			session = @editor.getSession()
-			markers = session.getMarkers()
+			# 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
-				marker_id = @changeIdToMarkerIdMap[change.id]
-				marker = markers[marker_id]
-				console.log "moving marker", {marker, start, end, change}
-				marker.range.start = start
-				marker.range.end = end
-	
-	class ChangesTracker extends EventEmitter
-		# 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
-		#   {d: "bar", p: 37} # Delete 'bar' at offset 37
-		# We only track the inserts and deletes, not the whole document, but by being given all
-		# updates that are applied to a document, we can update these appropriately.
-		# 
-		# Note that the set of inserts and deletes we store applies to the document as-is at the moment.
-		# So inserts correspond to text which is in the document, while deletes correspond to text which
-		# is no longer there, so their lengths do not affect the position of later offsets.
-		# E.g.
-		#             this is the current text of the document
-		#                         |-----|            |
-		#  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
-		#
-		# Track changes rules (should be consistent with Word):
-		#   * When text is inserted at a delete, the text goes to the left of the delete
-		#       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
-		#   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
-		#     removes the insert marker. E.g.
-		#       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
-		#           |---| <- inserted       |-| <- inserted
-		#       * Deletes overlapping regular text and inserted text will insert a delete marker for the
-		#         regular text:
-		#         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
-		#           |----|                  |--||
-		#           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
-		#                                      \--inserted 'bcde'
-		#   * Deletes overlapping other deletes are merged. E.g.
-		#      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
-		#          | <- delete 'def'     | <- delete 'bcdefg'
-		constructor: () ->
-			# Change objects have the following structure:
-			# {
-			#   id: ... # Uniquely generated by us
-			#   op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
-			#     i: "..."
-			#     p: 42
-			#   }
-			# }
-			#
-			# Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
-			# sync with Ace ranges.
-			@changes = []
-			@id = 0
-		
-		applyOp: (op) ->
-			# Apply an op that has been applied to the document to our changes to keep them up to date
-			if op.i?
-				@applyInsert(op)
-			else if op.d?
-				@applyDelete(op)
-			
-		applyInsert: (op) ->
-			op_start = op.p
-			op_length = op.i.length
-			op_end = op.p + op_length
-
-			already_merged = false
-			previous_change = null
-			moved_changes = []
-			for change in @changes
-				change_start = change.op.p
-				
-				if change.op.d?
-					# Shift any deletes after this along by the length of this insert
-					if op_start <= change_start
-						change.op.p += op_length
-						moved_changes.push change
-				else if change.op.i?
-					change_end = change_start + change.op.i.length
-					is_change_overlapping = (op_start >= change_start and op_start <= change_end)
-					
-					# 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.
-					# The previous op will be the delete, but it's already been shifted by this insert
-					#
-					# I.e.
-					# Originally: |-- existing insert --|
-					#             | <- existing delete at same offset
-					#
-					# Now:        |-- existing insert --| <- not shifted yet
-					#             |-- this insert --|| <- existing delete shifted along to end of this op
-					# 
-					# After:                         |-- existing insert --|  
-					#             |-- this insert --|| <- existing delete
-					# 
-					# Without the delete, the inserts would be merged.
-					is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
-
-					# If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
-					# then we merge them into one.
-					if is_change_overlapping and
-							!is_insert_blocked_by_delete and
-							!already_merged # With the way we order our changes, there should only ever be one candidate to merge
-							                # with since changes don't overlap. However, this flag just adds a little bit of protection
-						offset = op_start - change_start
-						change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
-						already_merged = true
-						moved_changes.push change
-					else if op_start <= change_start
-						# If we're fully before the other insert we can just shift the other insert by our length.
-						# If they are touching, and should have been merged, they will have been above.
-						# If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
-						change.op.p += op_length
-						moved_changes.push change
-				previous_change = change
-
-			if !already_merged
-				@_addOp op
-			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+				@_updateMarker(change.id, start, end)
+			@editor.renderer.updateBackMarkers()
+			@broadcastChange()
 		
-		applyDelete: (op) ->
-			op_start = op.p
-			op_length = op.d.length
-			op_end = op.p + op_length
-			remove_changes = []
-			moved_changes = []
-			
-			# We might end up modifying our delete op if it merges with existing deletes, or cancels out
-			# with an existing insert. Since we might do multiple modifications, we record them and do
-			# all the modifications after looping through the existing changes, so as not to mess up the
-			# offset indexes as we go.
-			op_modifications = []
-			for change in @changes
-				if change.op.i?
-					change_start = change.op.p
-					change_end = change_start + change.op.i.length
-					if op_end <= change_start
-						# Shift ops after us back by our length
-						change.op.p -= op_length
-						moved_changes.push change
-					else if op_start >= change_end
-						# Delete is after insert, nothing to do
-					else
-						# When the new delete overlaps an insert, we should remove the part of the insert that
-						# is now deleted, and also remove the part of the new delete that overlapped. I.e.
-						# the two cancel out where they overlap.
-						if op_start >= change_start
-							#                            |-- existing insert --|
-							# insert_remaining_before -> |.....||--   new delete   --|
-							delete_remaining_before = ""
-							insert_remaining_before = change.op.i.slice(0, op_start - change_start)
-						else
-							# delete_remaining_before -> |.....||-- existing insert --|
-							#                            |-- new delete   --|
-							delete_remaining_before = op.d.slice(0, change_start - op_start)
-							insert_remaining_before = ""
-
-						if op_end <= change_end
-							#    |--  existing insert  --|
-							# |--  new delete   --||.....| <- insert_remaining_after
-							delete_remaining_after = ""
-							insert_remaining_after = change.op.i.slice(op_end - change_start)
-						else
-							# |--  existing insert  --||.....| <- delete_remaining_after
-							#            |--  new delete   --|
-							delete_remaining_after = op.d.slice(change_end - op_start)
-							insert_remaining_after = ""
-
-						insert_remaining = insert_remaining_before + insert_remaining_after
-						if insert_remaining.length > 0
-							change.op.i = insert_remaining
-							change.op.p = Math.min(change_start, op_start)
-							moved_changes.push change
-						else
-							remove_changes.push change
-
-						# We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
-						# afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the 
-						# chunk in the middle not covered by these.
-						delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
-						delete_removed_start = delete_remaining_before.length
-						modification = {
-							d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
-							p: delete_removed_start
-						}
-						if modification.d.length > 0
-							op_modifications.push modification
-				else if change.op.d?
-					change_start = change.op.p
-					if op_end < change_start
-						# Shift ops after us (but not touching) back by our length
-						change.op.p -= op_length
-						moved_changes.push change
-					else if op_start <= change_start <= op_end
-						# If we overlap a delete, add it in our content, and delete the existing change
-						offset = change_start - op_start
-						op_modifications.push { i: change.op.d, p: offset }
-						remove_changes.push change
-			
-			op.d = @_applyOpModifications(op.d, op_modifications)
-			if op.d.length > 0
-				@_addOp op
-
-			for change in remove_changes
-				@_removeChange change
-			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
-
-		_newId: () ->
-			@id++
-
-		_addOp: (op) ->
-			change = {
-				id: @_newId()
-				op: op
-			}
-			@changes.push change
-
-			# Keep ops in order of offset, with deletes before inserts
-			@changes.sort (c1, c2) ->
-				result = c1.op.p - c2.op.p
-				if result != 0
-					return result
-				else if c1.op.i? and c2.op.d?
-					return 1
-				else
-					return -1
-
-			if op.d?
-				@emit "delete:added", change
-			else if op.i?
-				@emit "insert:added", change
-		
-		_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
-			
-		_applyOpModifications: (content, op_modifications) ->
-			# Put in descending position order, with deleting first if at the same offset 
-			# (Inserting first would modify the content that the delete will delete)
-			op_modifications.sort (a, b) ->
-				result = b.p - a.p 
-				if result != 0
-					return result
-				else if a.i? and b.d?
-					return 1
-				else
-					return -1
-
-			for modification in op_modifications
-				if modification.i?
-					content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
-				else if modification.d?
-					if content.slice(modification.p, modification.p + modification.d.length) != modification.d
-						throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
-					content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
-			return content
+		_onCommentMoved: (comment) ->
+			start = @_shareJsOffsetToAcePosition(comment.offset)
+			end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+			@_updateMarker(comment.id, start, end)
+			@editor.renderer.updateBackMarkers()
+			@broadcastChange()
+	
+		_updateMarker: (change_id, start, end) ->
+			return if !@changeIdToMarkerIdMap[change_id]?
+			session = @editor.getSession()
+			markers = session.getMarkers()
+			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
+			if background_marker_id?
+				background_marker = markers[background_marker_id]
+				background_marker.range.start = start
+				background_marker.range.end = end
+			if callout_marker_id?
+				callout_marker = markers[callout_marker_id]
+				callout_marker.range.start = start
+				callout_marker.range.end = start
 
-	return TrackChangesManager

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

@@ -207,16 +207,19 @@ define [
 			return doc.split("\n")
 
 		_aceDeltaSetsToSimpleDeltaSets: (aceDeltaSets, docLines) ->
+			simpleDeltaSets = []
 			for deltaSet in aceDeltaSets
-				simpleDeltas = []
-				for delta in deltaSet.deltas
-					simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
-					docLines = @_applyAceDeltasToDocLines([delta], docLines)
-				{
-					deltas: simpleDeltas
-					group: deltaSet.group
-				}
-			
+				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
+
 		_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
 			for deltaSet in simpleDeltaSets
 				aceDeltas = []
@@ -231,6 +234,17 @@ define [
 
 		_aceDeltaToSimpleDelta: (aceDelta, docLines) ->
 			start = aceDelta.start
+			if !start?
+				JSONstringifyWithCycles = (o) ->
+					seen = []
+					return JSON.stringify o, (k,v) ->
+						if (typeof v == 'object')
+							if ( seen.indexOf(v) >= 0 )
+								return '__cycle__'
+							seen.push(v);
+						return v
+				error = new Error("aceDelta had no start event: #{JSONstringifyWithCycles(aceDelta)}")
+				throw error
 			linesBefore = docLines.slice(0, start.row)
 			position =
 				linesBefore.join("").length + # full lines

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

@@ -64,7 +64,7 @@ class Doc
       server_ = @type.transform server, client, 'right'
       return [client_, server_]
   
-  _otApply: (docOp, isRemote) ->
+  _otApply: (docOp, isRemote, msg) ->
     oldSnapshot = @snapshot
     @snapshot = @type.apply(@snapshot, docOp)
 
@@ -72,7 +72,7 @@ class Doc
     # The reason is that the OT type APIs might need to access the snapshots to
     # determine information about the received op.
     @emit 'change', docOp, oldSnapshot
-    @emit 'remoteop', docOp, oldSnapshot if isRemote
+    @emit 'remoteop', docOp, oldSnapshot, msg if isRemote
   
   _connectionStateChanged: (state, data) ->
     switch state
@@ -185,7 +185,7 @@ class Doc
           # functionality, because its really a local op. Basically, the problem is that
           # if the client's op is rejected by the server, the editor window should update
           # to reflect the undo.
-          @_otApply undo, true
+          @_otApply undo, true, msg
         else
           @emit 'error', "Op apply failed (#{error}) and the op could not be reverted"
 
@@ -234,7 +234,7 @@ class Doc
         
       @version++
       # Finally, apply the op to @snapshot and trigger any event listeners
-      @_otApply docOp, true
+      @_otApply docOp, true, msg
 
     else if msg.meta
       {path, value} = msg.meta

+ 5 - 4
services/web/public/coffee/ide/history/HistoryManager.coffee

@@ -1,9 +1,10 @@
 define [
 	"moment"
+	"ide/colors/ColorManager"
 	"ide/history/controllers/HistoryListController"
 	"ide/history/controllers/HistoryDiffController"
 	"ide/history/directives/infiniteScroll"
-], (moment) ->
+], (moment, ColorManager) ->
 	class HistoryManager
 		constructor: (@ide, @$scope) ->
 			@reset()
@@ -172,13 +173,13 @@ define [
 						highlights.push {
 							label: "Added by #{name} on #{date}"
 							highlight: range
-							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
 						}
 					else if entry.d?
 						highlights.push {
 							label: "Deleted by #{name} on #{date}"
 							strikeThrough: range
-							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
 						}
 
 			return {text, highlights}
@@ -192,7 +193,7 @@ define [
 
 				for user in update.meta.users or []
 					if user?
-						user.hue = @ide.onlineUsersManager.getHueForUserId(user.id)
+						user.hue = ColorManager.getHueForUserId(user.id)
 
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 					update.meta.first_in_day = true

+ 2 - 2
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee

@@ -50,7 +50,7 @@ define -> [
 		regexToMatch: /No positions in optional float specifier/
 		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
 		humanReadableHint: """
-			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
+			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \\begin{figure}[h]), or remove the square brackets (e.g. \\begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 		"""
 	,
 		regexToMatch: /Undefined control sequence/
@@ -162,7 +162,7 @@ define -> [
 		regexToMatch: /LaTeX Error: \\verb ended by end of line/
 		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line"
 		humanReadableHint: """
-			You have used a \\verb command incorrectly. Try replacling the \\verb command with \begin{verbatim}\u2026\end{verbatim}.
+			You have used a \\verb command incorrectly. Try replacling the \\verb command with \\begin{verbatim}\u2026\\end{verbatim}.
 		"""
 	,
 		regexToMatch: /Illegal unit of measure (pt inserted)/

+ 4 - 19
services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee

@@ -1,7 +1,8 @@
 define [
+	"ide/colors/ColorManager"
 	"libs/md5"
 	"ide/online-users/controllers/OnlineUsersController"
-], () ->
+], (ColorManager) ->
 	class OnlineUsersManager
 
 		cursorUpdateInterval:500
@@ -46,7 +47,7 @@ define [
 					@refreshOnlineUsers()
 					
 			@$scope.getHueForUserId = (user_id) =>
-				@getHueForUserId(user_id)
+				ColorManager.getHueForUserId(user_id)
 
 		refreshOnlineUsers: () ->
 			@$scope.onlineUsersArray = []
@@ -74,7 +75,7 @@ define [
 					cursor:
 						row: client.row
 						column: client.column
-					hue: @getHueForUserId(client.user_id)
+					hue: ColorManager.getHueForUserId(client.user_id)
 				}
 
 			if @$scope.onlineUsersArray.length > 0
@@ -101,19 +102,3 @@ define [
 					delete @cursorUpdateTimeout
 				, @cursorUpdateInterval
 
-		OWN_HUE: 200 # We will always appear as this color to ourselves
-		ANONYMOUS_HUE: 100
-		getHueForUserId: (user_id) ->
-			if !user_id? or user_id == "anonymous-user"
-				return @ANONYMOUS_HUE
-
-			if window.user.id == user_id
-				return @OWN_HUE
-
-			hash = CryptoJS.MD5(user_id)
-			hue = parseInt(hash.toString().slice(0,8), 16) % 320
-			# Avoid 20 degrees either side of the personal hue
-			if hue > @OWNER_HUE - 20
-				hue = hue + 40
-			return hue
-

+ 4 - 4
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -29,12 +29,12 @@ define [
 
 		$scope.$watch "shouldShowLogs", (shouldShow) ->
 			if shouldShow
-				$scope.$applyAsync () -> 
+				$scope.$applyAsync () ->
 					$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
 
 		# log hints tracking
 		$scope.logHintsNegFeedbackValues = logHintsFeedback.feedbackOpts
-		
+
 		$scope.trackLogHintsLearnMore = () ->
 			event_tracking.sendMB "logs-hints-learn-more"
 
@@ -108,7 +108,7 @@ define [
 				_csrf: window.csrfToken
 			}, {params: params}
 
-		parseCompileResponse = (response) ->		
+		parseCompileResponse = (response) ->
 
 			# keep last url
 			last_pdf_url = $scope.pdf.url
@@ -469,7 +469,7 @@ define [
 
 			event_tracking.sendMB "subscription-start-trial", { source }
 
-			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
+			window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}")
 			$scope.startedFreeTrial = true
 
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->

+ 4 - 0
services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee

@@ -30,6 +30,10 @@ define [
 			TEXTLAYER_TIMEOUT: 100
 
 			constructor: (@url, @options) ->
+				# set up external character mappings - needed for Japanese etc
+				window.PDFJS.cMapUrl = './bcmaps/'
+				window.PDFJS.cMapPacked = true
+
 				if window.location?.search?.indexOf("disable-font-face=true") >= 0
 					window.PDFJS.disableFontFace = true
 				else

+ 6 - 1
services/web/public/coffee/ide/references/ReferencesManager.coffee

@@ -17,8 +17,13 @@ define [
 			# When we join the project:
 			#   index all references files
 			#   and don't broadcast to all clients
+			@inited = false
 			@$scope.$on 'project:joined', (e) =>
-				@indexAllReferences(false)
+				# We only need to grab the references when the editor first loads,
+				# not on every reconnect
+				if !@inited
+					@inited = true
+					@indexAllReferences(false)
 
 			setTimeout(
 				(self) ->

+ 455 - 0
services/web/public/coffee/ide/review-panel/ChangesTracker.coffee

@@ -0,0 +1,455 @@
+define [
+	"utils/EventEmitter"
+], (EventEmitter) ->
+	class ChangesTracker extends EventEmitter
+		# 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
+		#   {d: "bar", p: 37} # Delete 'bar' at offset 37
+		# We only track the inserts and deletes, not the whole document, but by being given all
+		# updates that are applied to a document, we can update these appropriately.
+		# 
+		# Note that the set of inserts and deletes we store applies to the document as-is at the moment.
+		# So inserts correspond to text which is in the document, while deletes correspond to text which
+		# is no longer there, so their lengths do not affect the position of later offsets.
+		# E.g.
+		#             this is the current text of the document
+		#                         |-----|            |
+		#  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
+		#
+		# Track changes rules (should be consistent with Word):
+		#   * When text is inserted at a delete, the text goes to the left of the delete
+		#       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
+		#   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
+		#     removes the insert marker. E.g.
+		#       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
+		#           |---| <- inserted       |-| <- inserted
+		#       * Deletes overlapping regular text and inserted text will insert a delete marker for the
+		#         regular text:
+		#         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
+		#           |----|                  |--||
+		#           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
+		#                                      \--inserted 'bcde'
+		#   * Deletes overlapping other deletes are merged. E.g.
+		#      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
+		#          | <- delete 'def'     | <- delete 'bcdefg'
+		#   * Deletes by another user will consume deletes by the first user
+		#   * Inserts by another user will not combine with inserts by the first user. If they are in the
+		#     middle of a previous insert by the first user, the original insert will be split into two.
+		constructor: () ->
+			# Change objects have the following structure:
+			# {
+			#   id: ... # Uniquely generated by us
+			#   op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
+			#     i: "..."
+			#     p: 42
+			#   }
+			# }
+			#
+			# Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
+			# sync with Ace ranges.
+			@changes = []
+			@comments = []
+			@id = 0
+		
+		addComment: (offset, length, metadata) ->
+			# TODO: Don't allow overlapping comments?
+			@comments.push comment = {
+				id: @_newId()
+				offset, length, metadata
+			}
+			@emit "comment:added", comment
+			return comment
+		
+		getComment: (comment_id) ->
+			comment = null
+			for c in @comments
+				if c.id == comment_id
+					comment = c
+					break
+			return comment
+		
+		resolveCommentId: (comment_id, resolved_data) ->
+			comment = @getComment(comment_id)
+			return if !comment?
+			comment.metadata.resolved = true
+			comment.metadata.resolved_data = resolved_data
+			@emit "comment:resolved", comment
+		
+		unresolveCommentId: (comment_id) ->
+			comment = @getComment(comment_id)
+			return if !comment?
+			comment.metadata.resolved = false
+			@emit "comment:unresolved", comment
+		
+		removeCommentId: (comment_id) ->
+			comment = @getComment(comment_id)
+			return if !comment?
+			@comments = @comments.filter (c) -> c.id != comment_id
+			@emit "comment:removed", comment
+		
+		getChange: (change_id) ->
+			change = null
+			for c in @changes
+				if c.id == change_id
+					change = c
+					break
+			return change
+
+		removeChangeId: (change_id) ->
+			change = @getChange(change_id)
+			return if !change?
+			@_removeChange(change)
+
+		applyOp: (op, metadata) ->
+			metadata.ts ?= new Date()
+			# Apply an op that has been applied to the document to our changes to keep them up to date
+			if op.i?
+				@applyInsertToChanges(op, metadata)
+				@applyInsertToComments(op)
+			else if op.d?
+				@applyDeleteToChanges(op, metadata)
+				@applyDeleteToComments(op)
+		
+		applyInsertToComments: (op) ->
+			for comment in @comments
+				if op.p <= comment.offset
+					comment.offset += op.i.length
+					@emit "comment:moved", comment
+				else if op.p < comment.offset + comment.length
+					comment.length += op.i.length
+					@emit "comment:moved", comment
+
+		applyDeleteToComments: (op) ->
+			op_start = op.p
+			op_length = op.d.length
+			op_end = op.p + op_length
+			for comment in @comments
+				comment_end = comment.offset + comment.length
+				if op_end <= comment.offset
+					# delete is fully before comment
+					comment.offset -= op_length
+					@emit "comment:moved", comment
+				else if op_start >= comment_end
+					# delete is fully after comment, nothing to do
+				else
+					# delete and comment overlap
+					delete_length_before = Math.max(0, comment.offset - op_start)
+					delete_length_after = Math.max(0, op_end - comment_end)
+					delete_length_overlapping = op_length - delete_length_before - delete_length_after
+					comment.offset = Math.min(comment.offset, op_start)
+					comment.length -= delete_length_overlapping
+					@emit "comment:moved", comment
+
+		applyInsertToChanges: (op, metadata) ->
+			op_start = op.p
+			op_length = op.i.length
+			op_end = op.p + op_length
+
+			already_merged = false
+			previous_change = null
+			moved_changes = []
+			remove_changes = []
+			new_changes = []
+			for change in @changes
+				change_start = change.op.p
+				
+				if change.op.d?
+					# Shift any deletes after this along by the length of this insert
+					if op_start < change_start
+						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
+							change.op.d = change.op.d.slice(op.i.length)
+							change.op.p += op.i.length
+							if change.op.d == ""
+								remove_changes.push change
+							else
+								moved_changes.push change
+							already_merged = true
+						else
+							change.op.p += op_length
+							moved_changes.push change
+				else if change.op.i?
+					change_end = change_start + change.op.i.length
+					is_change_overlapping = (op_start >= change_start and op_start <= change_end)
+					
+					# Only merge inserts if they are from the same user
+					is_same_user = metadata.user_id == change.metadata.user_id
+					
+					# 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.
+					# The previous op will be the delete, but it's already been shifted by this insert
+					#
+					# I.e.
+					# Originally: |-- existing insert --|
+					#             | <- existing delete at same offset
+					#
+					# Now:        |-- existing insert --| <- not shifted yet
+					#             |-- this insert --|| <- existing delete shifted along to end of this op
+					# 
+					# After:                         |-- existing insert --|  
+					#             |-- this insert --|| <- existing delete
+					# 
+					# Without the delete, the inserts would be merged.
+					is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
+
+					# If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
+					# then we merge them into one.
+					if @track_changes and
+							is_change_overlapping and
+							!is_insert_blocked_by_delete and
+							!already_merged and 
+							is_same_user
+						offset = op_start - change_start
+						change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
+						change.metadata.ts = metadata.ts
+						already_merged = true
+						moved_changes.push change
+					else if op_start <= change_start
+						# If we're fully before the other insert we can just shift the other insert by our length.
+						# If they are touching, and should have been merged, they will have been above.
+						# If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
+						change.op.p += op_length
+						moved_changes.push change
+					else if (!is_same_user or !@track_changes) and change_start < op_start < change_end
+						# This user is inserting inside a change by another user, so we need to split the
+						# other user's change into one before and after this one.
+						offset = op_start - change_start
+						before_content = change.op.i.slice(0, offset)
+						after_content = change.op.i.slice(offset)
+						
+						# The existing change can become the 'before' change
+						change.op.i = before_content
+						moved_changes.push change
+						
+						# Create a new op afterwards
+						after_change = {
+							op: {
+								i: after_content
+								p: change_start + offset + op_length
+							}
+							metadata: {}
+						}
+						after_change.metadata[key] = value for key, value of change.metadata
+						new_changes.push after_change
+						
+				previous_change = change
+
+			if @track_changes and !already_merged
+				@_addOp op, metadata
+			for {op, metadata} in new_changes
+				@_addOp op, metadata
+		
+			for change in remove_changes
+				@_removeChange change
+			
+			if moved_changes.length > 0
+				@emit "changes:moved", moved_changes
+		
+		applyDeleteToChanges: (op, metadata) ->
+			op_start = op.p
+			op_length = op.d.length
+			op_end = op.p + op_length
+			remove_changes = []
+			moved_changes = []
+			
+			# We might end up modifying our delete op if it merges with existing deletes, or cancels out
+			# with an existing insert. Since we might do multiple modifications, we record them and do
+			# all the modifications after looping through the existing changes, so as not to mess up the
+			# offset indexes as we go.
+			op_modifications = []
+			for change in @changes
+				if change.op.i?
+					change_start = change.op.p
+					change_end = change_start + change.op.i.length
+					if op_end <= change_start
+						# Shift ops after us back by our length
+						change.op.p -= op_length
+						moved_changes.push change
+					else if op_start >= change_end
+						# Delete is after insert, nothing to do
+					else
+						# When the new delete overlaps an insert, we should remove the part of the insert that
+						# is now deleted, and also remove the part of the new delete that overlapped. I.e.
+						# the two cancel out where they overlap.
+						if op_start >= change_start
+							#                            |-- existing insert --|
+							# insert_remaining_before -> |.....||--   new delete   --|
+							delete_remaining_before = ""
+							insert_remaining_before = change.op.i.slice(0, op_start - change_start)
+						else
+							# delete_remaining_before -> |.....||-- existing insert --|
+							#                            |-- new delete   --|
+							delete_remaining_before = op.d.slice(0, change_start - op_start)
+							insert_remaining_before = ""
+
+						if op_end <= change_end
+							#    |--  existing insert  --|
+							# |--  new delete   --||.....| <- insert_remaining_after
+							delete_remaining_after = ""
+							insert_remaining_after = change.op.i.slice(op_end - change_start)
+						else
+							# |--  existing insert  --||.....| <- delete_remaining_after
+							#            |--  new delete   --|
+							delete_remaining_after = op.d.slice(change_end - op_start)
+							insert_remaining_after = ""
+
+						insert_remaining = insert_remaining_before + insert_remaining_after
+						if insert_remaining.length > 0
+							change.op.i = insert_remaining
+							change.op.p = Math.min(change_start, op_start)
+							change.metadata.ts = metadata.ts
+							moved_changes.push change
+						else
+							remove_changes.push change
+
+						# We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
+						# afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the 
+						# chunk in the middle not covered by these.
+						delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
+						delete_removed_start = delete_remaining_before.length
+						modification = {
+							d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
+							p: delete_removed_start
+						}
+						if modification.d.length > 0
+							op_modifications.push modification
+				else if change.op.d?
+					change_start = change.op.p
+					if op_end < change_start or (!@track_changes and op_end == change_start)
+						# Shift ops after us back by our length.
+						# If we're tracking changes, it must be strictly before, since we'll merge 
+						# below if they are touching. Otherwise, touching is fine.
+						change.op.p -= op_length
+						moved_changes.push change
+					else if op_start <= change_start <= op_end
+						if @track_changes
+							# If we overlap a delete, add it in our content, and delete the existing change.
+							# It's easier to do it this way, rather than modifying the existing delete in case
+							# we overlap many deletes and we'd need to track that. We have a workaround to
+							# update the delete in place if possible below.
+							offset = change_start - op_start
+							op_modifications.push { i: change.op.d, p: offset }
+							remove_changes.push change
+						else
+							change.op.p = op_start
+							moved_changes.push change
+
+			# Copy rather than modify because we still need to apply it to comments
+			op = {
+				p: op.p
+				d: @_applyOpModifications(op.d, op_modifications)
+			}
+
+			for change in remove_changes
+				# This is a bit of hack to avoid removing one delete and replacing it with another.
+				# If we don't do this, it causes the UI to flicker
+				if op.d.length > 0 and change.op.d? and op.p <= change.op.p <= op.p + op.d.length
+					change.op.p = op.p
+					change.op.d = op.d
+					change.metadata = metadata
+					moved_changes.push change
+					op.d = "" # stop it being added
+				else
+					@_removeChange change
+
+			if @track_changes and op.d.length > 0
+				@_addOp op, metadata
+			else
+				# It's possible that we deleted an insert between two other inserts. I.e.
+				# If we delete 'user_2 insert' in:
+				#   |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
+				# it becomes:
+				#   |-- user_1 insert --||-- user_1 insert --|
+				# We need to merge these together again
+				results = @_scanAndMergeAdjacentUpdates()
+				moved_changes = moved_changes.concat(results.moved_changes)
+				for change in results.remove_changes
+					@_removeChange change
+					moved_changes = moved_changes.filter (c) -> c != change
+			
+			if moved_changes.length > 0
+				@emit "changes:moved", moved_changes
+
+		_newId: () ->
+			(@id++).toString()
+
+		_addOp: (op, metadata) ->
+			change = {
+				id: @_newId()
+				op: op
+				metadata: metadata
+			}
+			@changes.push change
+
+			# Keep ops in order of offset, with deletes before inserts
+			@changes.sort (c1, c2) ->
+				result = c1.op.p - c2.op.p
+				if result != 0
+					return result
+				else if c1.op.i? and c2.op.d?
+					return 1
+				else
+					return -1
+
+			if op.d?
+				@emit "delete:added", change
+			else if op.i?
+				@emit "insert:added", change
+		
+		_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
+			
+		_applyOpModifications: (content, op_modifications) ->
+			# Put in descending position order, with deleting first if at the same offset 
+			# (Inserting first would modify the content that the delete will delete)
+			op_modifications.sort (a, b) ->
+				result = b.p - a.p 
+				if result != 0
+					return result
+				else if a.i? and b.d?
+					return 1
+				else
+					return -1
+
+			for modification in op_modifications
+				if modification.i?
+					content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
+				else if modification.d?
+					if content.slice(modification.p, modification.p + modification.d.length) != modification.d
+						throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
+					content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
+			return content
+		
+		_scanAndMergeAdjacentUpdates: () ->
+			# This should only need calling when deleting an update between two
+			# other updates. There's no other way to get two adjacent updates from the
+			# same user, since they would be merged on insert.
+			previous_change = null
+			remove_changes = []
+			moved_changes = []
+			for change in @changes
+				if previous_change?.op.i? and change.op.i?
+					previous_change_end = previous_change.op.p + previous_change.op.i.length
+					previous_change_user_id = previous_change.metadata.user_id
+					change_start = change.op.p
+					change_user_id = change.metadata.user_id
+					if previous_change_end == change_start and previous_change_user_id == change_user_id
+						remove_changes.push change
+						previous_change.op.i += change.op.i
+						moved_changes.push previous_change
+				else if previous_change?.op.d? and change.op.d? and previous_change.op.p == change.op.p
+					# Merge adjacent deletes
+					previous_change.op.d += change.op.d
+					remove_changes.push change
+					moved_changes.push previous_change
+				else # Only update to the current change if we haven't removed it.
+					previous_change = change
+			return { moved_changes, remove_changes }

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

@@ -0,0 +1,9 @@
+define [
+	"ide/review-panel/controllers/ReviewPanelController"
+	"ide/review-panel/directives/reviewPanelSorted"
+	"ide/review-panel/directives/reviewPanelToggle"
+	"ide/review-panel/directives/changeEntry"
+	"ide/review-panel/directives/commentEntry"
+	"ide/review-panel/directives/addCommentEntry"
+	"ide/review-panel/filters/orderOverviewEntries"
+], () ->

+ 383 - 0
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -0,0 +1,383 @@
+define [
+	"base",
+	"utils/EventEmitter"
+	"ide/colors/ColorManager"
+	"ide/review-panel/ChangesTracker"
+], (App, EventEmitter, ColorManager, ChangesTracker) ->
+	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout) ->
+		$reviewPanelEl = $element.find "#review-panel"
+
+		$scope.SubViews =
+			CUR_FILE : "cur_file"
+			OVERVIEW : "overview"
+
+		$scope.reviewPanel =
+			entries: {}
+			trackNewChanges: false
+			hasEntries: false
+			subView: $scope.SubViews.CUR_FILE
+			openSubView: $scope.SubViews.CUR_FILE
+
+		$scope.commentState =
+			adding: false
+			content: ""
+
+		$scope.reviewPanelEventsBridge = new EventEmitter()
+
+		changesTrackers = {}
+
+		getDocEntries = (doc_id) ->
+			$scope.reviewPanel.entries[doc_id] ?= {}
+			return $scope.reviewPanel.entries[doc_id]
+
+		getChangeTracker = (doc_id) ->
+			changesTrackers[doc_id] ?= new ChangesTracker()
+			return changesTrackers[doc_id]
+
+		# TODO Just for prototyping purposes; remove afterwards.
+		mockedUserId = 'mock_user_id_1'
+		mockedUserId2 = 'mock_user_id_2'
+
+		if window.location.search.match /mocktc=true/
+			mock_changes = {
+				"main.tex":
+					changes: [{
+						op: { i: "Habitat loss and conflicts with humans are the greatest causes of concern.", p: 925 - 38 }
+						metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 30 * 60 * 1000) }
+					}, {
+						op: { d: "The lion is now a vulnerable species. ", p: 778 }
+						metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 31 * 60 * 1000) }
+					}]
+					comments: [{
+						offset: 1375 - 38
+						length: 79
+						metadata:
+							thread: [{
+								content: "Do we have a source for this?"
+								user_id: mockedUserId
+								ts: new Date(Date.now() - 45 * 60 * 1000) 
+							}]
+					}]
+				"chapter_1.tex":
+					changes: [{
+						"op":{"p":740,"d":", to take down large animals"},
+						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 15 * 60 * 1000)}
+					}, {
+						"op":{"i":", to keep hold of the prey","p":920},
+						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 130 * 60 * 1000)}
+					}, {
+						"op":{"i":" being","p":1057},
+						"metadata":{"user_id":mockedUserId2, ts: new Date(Date.now() - 72 * 60 * 1000)}
+					}]
+					comments:[{
+						"offset":111,"length":5,
+						"metadata":{
+							"thread": [
+								{"content":"Have we used 'pride' too much here?","user_id":mockedUserId, ts: new Date(Date.now() - 12 * 60 * 1000)},
+								{"content":"No, I think this is OK","user_id":mockedUserId2, ts: new Date(Date.now() - 9 * 60 * 1000)}
+							]
+						}
+					},{
+						"offset":452,"length":21,
+						"metadata":{
+							"thread":[
+								{"content":"TODO: Don't use as many parentheses!","user_id":mockedUserId2, ts: new Date(Date.now() - 99 * 60 * 1000)}
+							]
+						}
+					}]
+				"chapter_2.tex":
+					changes: [{
+						"op":{"p":458,"d":"other"},
+						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 133 * 60 * 1000)}
+					},{
+						"op":{"i":"usually 2-3, ","p":928},
+						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 27 * 60 * 1000)}
+					},{
+						"op":{"i":"If the parents are a male lion and a female tiger, it is called a liger. A tigon comes from a male tiger and a female lion.","p":1126},
+						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 152 * 60 * 1000)}
+					}]
+					comments: [{
+						"offset":299,"length":10,
+						"metadata":{
+							"thread":[{
+								"content":"Should we use a different word here if 'den' needs clarifying?","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
+							}]
+						}
+					},{
+						"offset":843,"length":66,
+						"metadata":{
+							"thread":[{
+								"content":"This sentence is a little ambiguous","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
+							}]
+						}
+					}]
+			}
+			ide.$scope.$on "file-tree:initialized", () ->
+				ide.fileTreeManager.forEachEntity (entity) ->
+					if mock_changes[entity.name]?
+						changesTracker = getChangeTracker(entity.id)
+						for change in mock_changes[entity.name].changes
+							changesTracker._addOp change.op, change.metadata 
+						for comment in mock_changes[entity.name].comments
+							changesTracker.addComment comment.offset, comment.length, comment.metadata 
+						for doc_id, changesTracker of changesTrackers
+							updateEntries(doc_id)
+
+		scrollbar = {}
+		$scope.reviewPanelEventsBridge.on "aceScrollbarVisibilityChanged", (isVisible, scrollbarWidth) ->
+			scrollbar = {isVisible, scrollbarWidth}
+			updateScrollbar()
+		
+		updateScrollbar = () ->
+			if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE
+				$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
+			else
+				$reviewPanelEl.css "right", "0"
+		
+		$scope.$watch "reviewPanel.subView", (subView) ->
+			return if !subView?
+			updateScrollbar()
+		
+		$scope.$watch "ui.reviewPanelOpen", (open) ->
+			return if !open?
+			if !open
+				# Always show current file when not open, but save current state
+				$scope.reviewPanel.openSubView = $scope.reviewPanel.subView
+				$scope.reviewPanel.subView = $scope.SubViews.CUR_FILE
+			else
+				# Reset back to what we had when previously open
+				$scope.reviewPanel.subView = $scope.reviewPanel.openSubView
+
+		$scope.$watch "editor.open_doc_id", (open_doc_id) ->
+			return if !open_doc_id?
+			changesTrackers[open_doc_id] ?= new ChangesTracker()
+			$scope.reviewPanel.changesTracker = changesTrackers[open_doc_id]
+
+		$scope.$watch (() ->
+			entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
+			Object.keys(entries).length
+		), (nEntries) ->
+			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.trackChangesFeatureFlag
+
+		$scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) ->
+			return if !reviewPanelOpen?
+			$timeout () ->
+				$scope.$broadcast "review-panel:toggle"
+				$scope.$broadcast "review-panel:layout"
+		
+		updateEntries = (doc_id) ->
+			changesTracker = getChangeTracker(doc_id)
+			entries = getDocEntries(doc_id)
+			
+			# Assume we'll delete everything until we see it, then we'll remove it from this object
+			delete_changes = {}
+			delete_changes[change_id] = true for change_id, change of entries
+
+			for change in changesTracker.changes
+				delete delete_changes[change.id]
+				entries[change.id] ?= {}
+					
+				# Update in place to avoid a full DOM redraw via angular
+				metadata = {}
+				metadata[key] = value for key, value of change.metadata
+				new_entry = {
+					type: if change.op.i then "insert" else "delete"
+					content: change.op.i or change.op.d
+					offset: change.op.p
+					metadata: change.metadata
+				}
+				for key, value of new_entry
+					entries[change.id][key] = value
+
+			for comment in changesTracker.comments
+				delete delete_changes[comment.id]
+				entries[comment.id] ?= {}
+				new_entry = {
+					type: "comment"
+					thread: comment.metadata.thread
+					resolved: comment.metadata.resolved
+					resolved_data: comment.metadata.resolved_data
+					offset: comment.offset
+					length: comment.length
+				}
+				for key, value of new_entry
+					entries[comment.id][key] = value
+
+			for change_id, _ of delete_changes
+				delete entries[change_id]
+
+		$scope.$on "editor:track-changes:changed", () ->
+			doc_id = $scope.editor.open_doc_id
+			updateEntries(doc_id)
+			$scope.$broadcast "review-panel:recalculate-screen-positions"
+			$scope.$broadcast "review-panel:layout"
+		
+		$scope.$on "editor:focus:changed", (e, cursor_offset, selection) ->
+			doc_id = $scope.editor.open_doc_id
+			entries = getDocEntries(doc_id)
+
+			if !selection
+				delete entries["add-comment"]
+			else
+				entries["add-comment"] = {
+					type: "add-comment"
+					offset: cursor_offset
+				}
+			
+			for id, entry of entries
+				if entry.type == "comment" and not entry.resolved
+					entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.length)
+				else if entry.type == "insert"
+					entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.content.length)
+				else if entry.type == "delete"
+					entry.focused = (entry.offset == cursor_offset)
+				else if entry.type == "add-comment" and selection
+					entry.focused = true
+			
+			$scope.$broadcast "review-panel:recalculate-screen-positions"
+			$scope.$broadcast "review-panel:layout"
+		
+		$scope.acceptChange = (entry_id) ->
+			$scope.$broadcast "change:accept", entry_id
+		
+		$scope.rejectChange = (entry_id) ->
+			$scope.$broadcast "change:reject", entry_id
+		
+		$scope.startNewComment = () ->
+			# $scope.commentState.adding = true
+			$scope.$broadcast "comment:select_line"
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+		
+		$scope.submitNewComment = (content) ->
+			# $scope.commentState.adding = false
+			$scope.$broadcast "comment:add", content
+			# $scope.commentState.content = ""
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+		
+		$scope.cancelNewComment = (entry) ->
+			# $scope.commentState.adding = false
+			# $scope.commentState.content = ""
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+		
+		$scope.startReply = (entry) ->
+			entry.replying = true
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+
+		# $scope.handleCommentReplyKeyPress = (ev, entry) ->
+		# 	if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
+		# 		ev.preventDefault()
+		# 		ev.target.blur()
+		# 		$scope.submitReply(entry)
+
+		$scope.submitReply = (entry, entry_id) ->
+			$scope.unresolveComment(entry_id)
+			entry.thread.push {
+				content: entry.replyContent
+				ts: new Date()
+				user_id: window.user_id
+			}
+			entry.replyContent = ""
+			entry.replying = false
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+			# TODO Just for prototyping purposes; remove afterwards
+			window.setTimeout((() -> 
+				$scope.$applyAsync(() -> submitMockedReply(entry))
+			), 1000 * 2)
+
+		# TODO Just for prototyping purposes; remove afterwards.
+		submitMockedReply = (entry) ->
+			entry.thread.push {
+				content: 'Sounds good!'
+				ts: new Date()
+				user_id: mockedUserId
+			}
+			entry.replyContent = ""
+			entry.replying = false
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+		
+		$scope.cancelReply = (entry) ->
+			entry.replying = false
+			entry.replyContent = ""
+			$scope.$broadcast "review-panel:layout"
+
+		$scope.resolveComment = (entry, entry_id) ->
+			entry.showWhenResolved = false
+			entry.focused = false
+			$scope.$broadcast "comment:resolve", entry_id, window.user_id
+		
+		$scope.unresolveComment = (entry_id) ->
+			$scope.$broadcast "comment:unresolve", entry_id
+		
+		$scope.deleteComment = (entry_id) ->
+			$scope.$broadcast "comment:remove", entry_id
+
+		$scope.showThread = (entry) ->
+			entry.showWhenResolved = true
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+
+		$scope.hideThread = (entry) ->
+			entry.showWhenResolved = false
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+
+		$scope.setSubView = (subView) -> 
+			$scope.reviewPanel.subView = subView
+						
+		$scope.gotoEntry = (doc_id, entry) ->
+			ide.editorManager.openDocId(doc_id, { gotoOffset: entry.offset })
+
+		DOC_ID_NAMES = {} 
+		$scope.getFileName = (doc_id) ->
+			# This is called a lot and is relatively expensive, so cache the result
+			if !DOC_ID_NAMES[doc_id]?
+				entity = ide.fileTreeManager.findEntityById(doc_id)
+				return if !entity?
+				DOC_ID_NAMES[doc_id] = ide.fileTreeManager.getEntityPath(entity)
+			return DOC_ID_NAMES[doc_id]
+
+		# TODO: Eventually we need to get this from the server, and update it 
+		# when we get an id we don't know. This'll do for client side testing
+		refreshUsers = () ->
+			$scope.users = {}
+			# TODO Just for prototyping purposes; remove afterwards.
+			$scope.users[mockedUserId] = {
+				email: "paulo@sharelatex.com"
+				name: "Paulo Reis"
+				isSelf: false
+				hue: 70
+				avatar_text: "PR"
+			}
+			$scope.users[mockedUserId2] = {
+				email: "james@sharelatex.com"
+				name: "James Allen"
+				isSelf: false
+				hue: 320
+				avatar_text: "JA"
+			}
+
+			for member in $scope.project.members.concat($scope.project.owner)
+				if member._id == window.user_id
+					name = "You"
+					isSelf = true
+				else
+					name = "#{member.first_name} #{member.last_name}"
+					isSelf = false
+
+				$scope.users[member._id] = {
+					email: member.email
+					name: name
+					isSelf: isSelf
+					hue: ColorManager.getHueForUserId(member._id)
+					avatar_text: [member.first_name, member.last_name].filter((n) -> n?).map((n) -> n[0]).join ""
+				}
+		
+		$scope.$watch "project.members", (members) ->
+			return if !members?
+			refreshUsers()

+ 35 - 0
services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee

@@ -0,0 +1,35 @@
+define [
+	"base"
+], (App) ->
+	App.directive "addCommentEntry", () ->
+		restrict: "E"
+		templateUrl: "addCommentEntryTemplate"
+		scope: 
+			onStartNew: "&"
+			onSubmit: "&"
+			onCancel: "&"
+			onIndicatorClick: "&"
+		link: (scope, element, attrs) ->
+			scope.state =
+				isAdding: false
+				content: ""
+
+			scope.startNewComment = () ->
+				scope.state.isAdding = true
+				scope.onStartNew()
+
+			scope.cancelNewComment = () ->
+				scope.state.isAdding = false
+				scope.onCancel()
+
+			scope.handleCommentKeyPress = (ev) ->
+				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
+					ev.preventDefault()
+					ev.target.blur()
+					scope.submitNewComment()
+
+			scope.submitNewComment = () ->
+				console.log scope.state.content
+				scope.onSubmit { content: scope.state.content }
+				scope.state.isAdding = false
+				scope.state.content = ""

+ 13 - 0
services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee

@@ -0,0 +1,13 @@
+define [
+	"base"
+], (App) ->
+	App.directive "changeEntry", () ->
+		restrict: "E"
+		templateUrl: "changeEntryTemplate"
+		scope: 
+			entry: "="
+			user: "="
+			onAccept: "&"
+			onReject: "&"
+			onIndicatorClick: "&"
+		

+ 23 - 0
services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee

@@ -0,0 +1,23 @@
+define [
+	"base"
+], (App) ->
+	App.directive "commentEntry", () ->
+		restrict: "E"
+		templateUrl: "commentEntryTemplate"
+		scope: 
+			entry: "="
+			users: "="
+			onResolve: "&"
+			onReply: "&"
+			onIndicatorClick: "&"
+			onDelete: "&"
+			onUnresolve: "&"
+			onShowThread: "&"
+			onHideThread: "&"
+		link: (scope, element, attrs) ->
+			scope.handleCommentReplyKeyPress = (ev) ->
+				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
+					ev.preventDefault()
+					ev.target.blur()
+					scope.onReply()
+		

+ 134 - 0
services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee

@@ -0,0 +1,134 @@
+define [
+	"base"
+], (App) ->
+	App.directive "reviewPanelSorted", ($timeout) ->
+		return  {
+			link: (scope, element, attrs) ->
+				previous_focused_entry_index = 0
+				
+				layout = () ->
+					sl_console.log "LAYOUT"
+					if scope.ui.reviewPanelOpen
+						PADDING = 8
+						TOOLBAR_HEIGHT = 38
+					else
+						PADDING = 4
+						TOOLBAR_HEIGHT = 4
+					
+					entries = []
+					for el in element.find(".rp-entry-wrapper")
+						entry = {
+							$indicator_el: $(el).find(".rp-entry-indicator")
+							$box_el: $(el).find(".rp-entry")
+							$callout_el: $(el).find(".rp-entry-callout")
+							scope: angular.element(el).scope()
+						}
+						if scope.ui.reviewPanelOpen
+							entry.$layout_el = entry.$box_el
+						else
+							entry.$layout_el = entry.$indicator_el
+						entries.push entry
+					entries.sort (a,b) -> a.scope.entry.offset - b.scope.entry.offset
+					
+					return if entries.length == 0
+					
+					focused_entry_index = Math.min(previous_focused_entry_index, entries.length - 1)
+					for entry, i in entries
+						if entry.scope.entry.focused
+							focused_entry_index = i
+							break
+					entries_after = entries.slice(focused_entry_index + 1)
+					entries_before = entries.slice(0, focused_entry_index)
+					focused_entry = entries[focused_entry_index]
+					previous_focused_entry_index = focused_entry_index
+					
+					sl_console.log "focused_entry_index", focused_entry_index
+					
+					line_height = 15
+					
+					# Put the focused entry exactly where it wants to be
+					focused_entry_top = Math.max(TOOLBAR_HEIGHT, focused_entry.scope.entry.screenPos.y)
+					focused_entry.$box_el.css(top: focused_entry_top)
+					focused_entry.$indicator_el.css(top: focused_entry_top)
+					focused_entry.$callout_el.css(top: focused_entry_top + line_height, height: 0)
+					
+					previousBottom = focused_entry_top + focused_entry.$layout_el.height()
+					for entry in entries_after
+						original_top = entry.scope.entry.screenPos.y
+						height = entry.$layout_el.height()
+						top = Math.max(original_top, previousBottom + PADDING)
+						previousBottom = top + height
+						entry.$box_el.css(top: top)
+						entry.$indicator_el.css(top: top)
+						entry.$callout_el.removeClass("rp-entry-callout-inverted")
+						entry.$callout_el.css(top: original_top + line_height, height: top - original_top)
+						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
+					
+					previousTop = focused_entry_top
+					entries_before.reverse() # Work through backwards, starting with the one just above
+					for entry in entries_before
+						original_top = entry.scope.entry.screenPos.y
+						height = entry.$layout_el.height()
+						original_bottom = original_top + height
+						bottom = Math.min(original_bottom, previousTop - PADDING)
+						top = bottom - height
+						previousTop = top
+						entry.$box_el.css(top: top)
+						entry.$indicator_el.css(top: top)
+						entry.$callout_el.addClass("rp-entry-callout-inverted")
+						entry.$callout_el.css(top: top + line_height + 1, height: original_top - top)
+						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
+				
+				scope.$applyAsync () ->
+					layout()
+				
+				scope.$on "review-panel:layout", () ->
+					scope.$applyAsync () ->
+						layout()
+
+				## Scroll lock with Ace
+				scroller = element
+				list = element.find(".rp-entry-list-inner")
+				
+				# If we listen for scroll events in the review panel natively, then with a Mac trackpad
+				# the scroll is very smooth (natively done I'd guess), but we don't get polled regularly
+				# enough to keep Ace in step, and it noticeably lags. If instead, we borrow the manual
+				# mousewheel/trackpad scrolling behaviour from Ace, and turn mousewheel events into
+				# scroll events ourselves, then it makes the review panel slightly less smooth (barely)
+				# noticeable, but keeps it perfectly in step with Ace.
+				ace.require("ace/lib/event").addMouseWheelListener scroller[0], (e) ->
+					deltaY = e.wheelY
+					old_top = parseInt(list.css("top"))
+					top = Math.min(0, old_top - deltaY * 4)
+					list.css(top: top)
+					scrollAce(-top)
+					e.preventDefault()
+
+				# Use these to avoid unnecessary updates. Scrolling one
+				# panel causes us to scroll the other panel, but there's no
+				# need to trigger the event back to the original panel.
+				ignoreNextPanelEvent = false
+				ignoreNextAceEvent = false
+
+				scrollPanel = (scrollTop, height) ->
+					if ignoreNextAceEvent
+						ignoreNextAceEvent = false
+					else
+						ignoreNextPanelEvent = true
+						list.height(height)
+						# console.log({height, scrollTop, top: height - scrollTop})
+						list.css(top: - scrollTop)
+			
+				scrollAce = (scrollTop) ->
+					if ignoreNextPanelEvent
+						ignoreNextPanelEvent = false
+					else
+						ignoreNextAceEvent = true
+						scope.reviewPanelEventsBridge.emit "externalScroll", scrollTop
+				
+				scope.reviewPanelEventsBridge.on "aceScroll", scrollPanel
+				scope.$on "$destroy", () ->
+					scope.reviewPanelEventsBridge.off "aceScroll"
+				
+				scope.reviewPanelEventsBridge.emit "refreshScrollPosition"
+		}	

+ 14 - 0
services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee

@@ -0,0 +1,14 @@
+define [
+	"base"
+], (App) ->
+	App.directive "reviewPanelToggle", () ->
+		restrict: "E"
+		scope: 
+			innerModel: '=ngModel'
+		template: """
+<div class="rp-toggle">
+	<input id="rp-toggle-{{$id}}" type="checkbox" class="rp-toggle-hidden-input" ng-model="innerModel" />
+	<label for="rp-toggle-{{$id}}" class="rp-toggle-btn"></label>
+</div>
+"""
+	

+ 11 - 0
services/web/public/coffee/ide/review-panel/filters/orderOverviewEntries.coffee

@@ -0,0 +1,11 @@
+define [
+	"base"
+], (App) ->
+	App.filter "orderOverviewEntries", () ->
+		(items) ->
+			array = []
+			for key, value of items
+				value.entry_id = key
+				array.push value
+			array.sort (a, b) -> a.offset - b.offset
+			return array

+ 13 - 0
services/web/public/coffee/ide/services/ide.coffee

@@ -40,6 +40,19 @@ define [
 					message: -> message
 			}
 
+		ide.showLockEditorMessageModal = (title, message) ->
+			# modal to block the editor when connection is down
+			$modal.open {
+				templateUrl: "lockEditorModalTemplate"
+				controller:  "GenericMessageModalController"
+				backdrop:    "static" # prevent dismiss by click on background
+				keyboard:    false    # prevent dismiss via keyboard
+				resolve:
+					title:   -> title
+					message: -> message
+				windowClass: "lock-editor-modal"
+			}
+
 		return ide
 	]
 

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

@@ -30,7 +30,7 @@ define [
 
 		$scope.$watch "settings.syntaxValidation", (syntaxValidation, oldSyntaxValidation) =>
 			if syntaxValidation != oldSyntaxValidation
-				settings.saveProjectSettings({syntaxValidation: syntaxValidation})
+				settings.saveSettings({syntaxValidation: syntaxValidation})
 
 		$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
 			return if @ignoreUpdates

+ 26 - 13
services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee

@@ -8,6 +8,7 @@ define [
 		}
 		$scope.state = {
 			error: null
+			errorReason: null
 			inflight: false
 			startedFreeTrial: false
 			invites: []
@@ -19,11 +20,16 @@ define [
 			, 200
 
 		INFINITE_COLLABORATORS = -1
-		$scope.$watch "(project.members.length + project.invites.length)", (noOfMembers) ->
+
+		$scope.refreshCanAddCollaborators = () ->
 			allowedNoOfMembers = $scope.project.features.collaborators
-			$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
+			$scope.canAddCollaborators = (
+				($scope.project.members.length + $scope.project.invites.length) < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
+			)
+		$scope.refreshCanAddCollaborators()
 
-		window._m = projectMembers
+		$scope.$watch "(project.members.length + project.invites.length)", (_noOfMembers) ->
+			$scope.refreshCanAddCollaborators()
 
 		$scope.autocompleteContacts = []
 		do loadAutocompleteUsers = () ->
@@ -64,7 +70,8 @@ define [
 
 				members = $scope.inputs.contacts
 				$scope.inputs.contacts = []
-				$scope.state.error = null
+				$scope.state.error = false
+				$scope.state.errorReason = null
 				$scope.state.inflight = true
 
 				if !$scope.project.invites?
@@ -96,17 +103,22 @@ define [
 
 					request
 						.success (data) ->
-							if data.invite
-								invite = data.invite
-								$scope.project.invites.push invite
+							if data.error
+								$scope.state.error = true
+								$scope.state.errorReason = "#{data.error}"
+								$scope.state.inflight = false
 							else
-								if data.users?
-									users = data.users
-								else if data.user?
-									users = [data.user]
+								if data.invite
+									invite = data.invite
+									$scope.project.invites.push invite
 								else
-									users = []
-								$scope.project.members.push users...
+									if data.users?
+										users = data.users
+									else if data.user?
+										users = [data.user]
+									else
+										users = []
+									$scope.project.members.push users...
 
 							setTimeout () ->
 								# Give $scope a chance to update $scope.canAddCollaborators
@@ -116,6 +128,7 @@ define [
 						.error () ->
 							$scope.state.inflight = false
 							$scope.state.error = true
+							$scope.state.errorReason = null
 
 			$timeout addMembers, 50 # Give email list a chance to update
 

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

@@ -14,6 +14,7 @@ define [
 	"main/subscription-dashboard"
 	"main/new-subscription"
 	"main/annual-upgrade"
+	"main/announcements"
 	"main/register-users"
 	"main/subscription/group-subscription-invite-controller"
 	"main/contact-us"
@@ -32,4 +33,18 @@ define [
 	"filters/formatDate"
 	"__MAIN_CLIENTSIDE_INCLUDES__"
 ], () ->
-	angular.bootstrap(document.body, ["SharelatexApp"])
+	angular.module('SharelatexApp').config(
+		($locationProvider) ->
+			try
+				$locationProvider.html5Mode({
+					enabled: false,
+					requireBase: false,
+					rewriteLinks: false
+				})
+			catch e
+				console.error "Error while trying to fix '#' links: ", e
+	)
+	angular.bootstrap(
+		document.body,
+		["SharelatexApp"]
+	)

+ 27 - 6
services/web/public/coffee/main/account-settings.coffee

@@ -29,10 +29,13 @@ define [
 	App.controller "DeleteAccountModalController", [
 		"$scope", "$modalInstance", "$timeout", "$http",
 		($scope,   $modalInstance,   $timeout,   $http) ->
-			$scope.state = 
+			$scope.state =
 				isValid : false
 				deleteText: ""
+				password: ""
 				inflight: false
+				error: false
+				invalidCredentials: false
 
 			$modalInstance.opened.then () ->
 				$timeout () ->
@@ -40,20 +43,38 @@ define [
 				, 700
 
 			$scope.checkValidation = ->
-				$scope.state.isValid = $scope.state.deleteText == $scope.email
+				$scope.state.isValid = $scope.state.deleteText == $scope.email and $scope.state.password.length > 0
 
 			$scope.delete = () ->
 				$scope.state.inflight = true
-
+				$scope.state.error = false
+				$scope.state.invalidCredentials = false
 				$http({
-						method: "DELETE"
-						url: "/user"
+						method: "POST"
+						url: "/user/delete"
 						headers:
 							"X-CSRF-Token": window.csrfToken
+							"Content-Type": 'application/json'
+						data:
+							password: $scope.state.password
+						disableAutoLoginRedirect: true # we want to handle errors ourselves
 					})
 					.success () ->
 						$modalInstance.close()
-						window.location = "/"
+						$scope.state.inflight = false
+						$scope.state.error = false
+						$scope.state.invalidCredentials = false
+						setTimeout(
+							() ->
+								window.location = "/login"
+							, 1000
+						)
+					.error (data, status) ->
+						$scope.state.inflight = false
+						if status == 403
+							$scope.state.invalidCredentials = true
+						else
+							$scope.state.error = true
 
 			$scope.cancel = () ->
 				$modalInstance.dismiss('cancel')

+ 29 - 9
services/web/public/coffee/main/account-upgrade.coffee

@@ -6,14 +6,34 @@ define [
 		$scope.buttonClass = "btn-primary"
 
 		$scope.startFreeTrial = (source, couponCode) ->
-			event_tracking.sendMB "subscription-start-trial", { source }
+			plan = 'collaborator_free_trial_7_days'
 
 			w = window.open()
-			sixpack.convert "track-changes-discount", ->
-				sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->
-					ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-					url = "/user/subscription/new?planCode=#{planName}_free_trial_7_days&ssp=#{planName == 'collaborator'}"
-					if couponCode?
-						url = "#{url}&cc=#{couponCode}"
-					$scope.startedFreeTrial = true
-					w.location = url
+			go = () ->
+				ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
+				url = "/user/subscription/new?planCode=#{plan}&ssp=true"
+				if couponCode?
+					url = "#{url}&cc=#{couponCode}"
+				$scope.startedFreeTrial = true
+
+				switch source
+					when "dropbox"
+						sixpack.participate 'teaser-dropbox-text', ['default', 'dropbox-focused'], (variant) ->
+							event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
+
+					when "history"
+						sixpack.participate 'teaser-history', ['default', 'focused'], (variant) ->
+							event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
+
+					else
+						event_tracking.sendMB "subscription-start-trial", { source, plan }
+				
+				w.location = url
+
+			if $scope.shouldABTestPlans
+				sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
+					if chosenVariation in ['heron', 'ibis']
+						plan = "collaborator_#{chosenVariation}"
+					go()
+			else
+				go()

+ 29 - 0
services/web/public/coffee/main/announcements.coffee

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

+ 0 - 10
services/web/public/coffee/main/event.coffee

@@ -53,16 +53,6 @@ define [
 					@sendMB key, segmentation
 		}
 
-	# App.directive "countlyTrack", () ->
-	# 	return {
-	# 		restrict: "A"
-	# 		scope: false,
-	# 		link: (scope, el, attrs) ->
-	# 			eventKey  = attrs.countlyTrack
-	# 			if (eventKey?)
-	# 				el.on "click", () ->
-	# 					console.log eventKey
-	# 	}
 
 	#header
 	$('.navbar a').on "click", (e)->

+ 5 - 2
services/web/public/coffee/main/new-subscription.coffee

@@ -5,12 +5,15 @@ define [
 
 	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils)->
 		throw new Error("Recurly API Library Missing.")  if typeof recurly is "undefined"
-	
+
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.plans = MultiCurrencyPricing.plans
 
 		$scope.switchToStudent = ()->
-			window.location = "/user/subscription/new?planCode=student_free_trial_7_days&currency=#{$scope.currencyCode}&cc=#{$scope.data.coupon}"
+			currentPlanCode = window.plan_code
+			planCode = currentPlanCode.replace('collaborator', 'student')
+			event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code }
+			window.location = "/user/subscription/new?planCode=#{planCode}&currency=#{$scope.currencyCode}&cc=#{$scope.data.coupon}"
 
 		event_tracking.sendMB "subscription-form", { plan : window.plan_code }
 

+ 204 - 12
services/web/public/coffee/main/plans.coffee

@@ -5,12 +5,171 @@ define [
 
 
 	App.factory "MultiCurrencyPricing", () ->
-		
+
 		currencyCode = window.recomendedCurrency
 
 		return {
 			currencyCode:currencyCode
-			plans: 
+
+			heron:
+				USD:
+					student:
+						monthly: "$6"
+						annual: "$60"
+					collaborator:
+						monthly: "$12"
+						annual: "$144"
+				EUR:
+					student:
+						monthly: "€5"
+						annual: "€50"
+					collaborator:
+						monthly: "€11"
+						annual: "€132"
+				GBP:
+					student:
+						monthly: "£5"
+						annual: "£50"
+					collaborator:
+						monthly: "£10"
+						annual: "£120"
+				SEK:
+					student:
+						monthly: "45 kr"
+						annual: "450 kr"
+					collaborator:
+						monthly: "90 kr"
+						annual: "1080 kr"
+				CAD:
+					student:
+						monthly: "$7"
+						annual: "$70"
+					collaborator:
+						monthly: "$14"
+						annual: "$168"
+				NOK:
+					student:
+						monthly: "45 kr"
+						annual: "450 kr"
+					collaborator:
+						monthly: "90 kr"
+						annual: "1080 kr"
+				DKK:
+					student:
+						monthly: "40 kr"
+						annual: "400 kr"
+					collaborator:
+						monthly: "70 kr"
+						annual: "840 kr"
+				AUD:
+					student:
+						monthly: "$8"
+						annual: "$80"
+					collaborator:
+						monthly: "$15"
+						annual: "$180"
+				NZD:
+					student:
+						monthly: "$8"
+						annual: "$80"
+					collaborator:
+						monthly: "$15"
+						annual: "$180"
+				CHF:
+					student:
+						monthly: "Fr 6"
+						annual: "Fr 60"
+					collaborator:
+						monthly: "Fr 12"
+						annual: "Fr 144"
+				SGD:
+					student:
+						monthly: "$8"
+						annual: "$80"
+					collaborator:
+						monthly: "$16"
+						annual: "$192"
+
+			ibis:
+				USD:
+					student:
+						monthly: "$10"
+						annual: "$100"
+					collaborator:
+						monthly: "$18"
+						annual: "$216"
+				EUR:
+					student:
+						monthly: "€9"
+						annual: "€90"
+					collaborator:
+						monthly: "€17"
+						annual: "€204"
+				GBP:
+					student:
+						monthly: "£7"
+						annual: "£70"
+					collaborator:
+						monthly: "£14"
+						annual: "£168"
+				SEK:
+					student:
+						monthly: "75 kr"
+						annual: "750 kr"
+					collaborator:
+						monthly: "140 kr"
+						annual: "1680 kr"
+				CAD:
+					student:
+						monthly: "$12"
+						annual: "$120"
+					collaborator:
+						monthly: "$22"
+						annual: "$264"
+				NOK:
+					student:
+						monthly: "75 kr"
+						annual: "750 kr"
+					collaborator:
+						monthly: "140 kr"
+						annual: "1680 kr"
+				DKK:
+					student:
+						monthly: "68 kr"
+						annual: "680 kr"
+					collaborator:
+						monthly: "110 kr"
+						annual: "1320 kr"
+				AUD:
+					student:
+						monthly: "$13"
+						annual: "$130"
+					collaborator:
+						monthly: "$22"
+						annual: "$264"
+				NZD:
+					student:
+						monthly: "$14"
+						annual: "$140"
+					collaborator:
+						monthly: "$22"
+						annual: "$264"
+				CHF:
+					student:
+						monthly: "Fr 10"
+						annual: "Fr 100"
+					collaborator:
+						monthly: "Fr 18"
+						annual: "Fr 216"
+				SGD:
+					student:
+						monthly: "$14"
+						annual: "$140"
+					collaborator:
+						monthly: "$25"
+						annual: "$300"
+
+			plans:
 				USD:
 					symbol: "$"
 					student:
@@ -23,7 +182,7 @@ define [
 						monthly: "$30"
 						annual: "$360"
 
-				EUR: 
+				EUR:
 					symbol: "€"
 					student:
 						monthly: "€7"
@@ -34,7 +193,7 @@ define [
 					professional:
 						monthly: "€28"
 						annual: "€336"
-						
+
 				GBP:
 					symbol: "£"
 					student:
@@ -117,7 +276,7 @@ define [
 					professional:
 						monthly: "$35"
 						annual: "$420"
-						
+
 				CHF:
 					symbol: "Fr"
 					student:
@@ -141,36 +300,69 @@ define [
 					professional:
 						monthly: "$40"
 						annual: "$480"
+
 		}
-	
 
 
+	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) ->
+
+		$scope.showPlans = false
+
+		$scope.plansVariant = 'default'
+		$scope.shouldABTestPlans = window.shouldABTestPlans
 
-	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http) ->
+		if $scope.shouldABTestPlans
+			sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
+				$scope.plansVariant = chosenVariation
+				event_tracking.sendMB 'plans-page', {plans_variant: chosenVariation}
+				if chosenVariation in ['heron', 'ibis']
+					# overwrite student plans with alternative
+					for currency, _v of $scope.plans
+						$scope.plans[currency]['student'] = MultiCurrencyPricing[chosenVariation][currency]['student']
+						$scope.plans[currency]['collaborator'] = MultiCurrencyPricing[chosenVariation][currency]['collaborator']
+				$scope.showPlans = true
+		else
+			$scope.showPlans = true
 
 		$scope.plans = MultiCurrencyPricing.plans
+
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 
 		$scope.trial_len = 7
+
 		$scope.planQueryString = '_free_trial_7_days'
 
 		$scope.ui =
 			view: "monthly"
 
-
 		$scope.changeCurreny = (newCurrency)->
 			$scope.currencyCode = newCurrency
 
+		# because ternary logic in angular bindings is hard
+		$scope.getCollaboratorPlanCode = () ->
+			view = $scope.ui.view
+			variant = $scope.plansVariant
+			if view == "annual"
+				if variant == "default"
+					return "collaborator-annual"
+				else
+					return "collaborator-annual_#{variant}"
+			else
+				if variant == "default"
+					return "collaborator#{$scope.planQueryString}"
+				else
+					return "collaborator_#{variant}"
+
 		$scope.signUpNowClicked = (plan, annual)->
+			event_tracking.sendMB 'plans-page-start-trial', {plan}
 			if $scope.ui.view == "annual"
 				plan = "#{plan}_annual"
-			
-			event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
+			event_tracking.send   'subscription-funnel', 'sign_up_now_button', plan
 
 		$scope.switchToMonthly = ->
 			$scope.ui.view = "monthly"
 			event_tracking.send 'subscription-funnel', 'plans-page', 'monthly-prices'
-		
+
 		$scope.switchToStudent = ->
 			$scope.ui.view = "student"
 			event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices'
@@ -178,7 +370,7 @@ define [
 		$scope.switchToAnnual = ->
 			$scope.ui.view = "annual"
 			event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices'
-			
+
 		$scope.openGroupPlanModal = () ->
 			$modal.open {
 				templateUrl: "groupPlanModalTemplate"

+ 1 - 1
services/web/public/coffee/main/project-list/project-list.coffee

@@ -99,7 +99,7 @@ define [
 				visible = true
 				# Only show if it matches any search text
 				if $scope.searchText.value? and $scope.searchText.value != ""
-					if !project.name.toLowerCase().match($scope.searchText.value.toLowerCase())
+					if project.name.toLowerCase().indexOf($scope.searchText.value.toLowerCase()) == -1
 						visible = false
 				# Only show if it matches the selected tag
 				if $scope.filter == "tag" and selectedTag? and project.id not in selectedTag.project_ids

+ 28 - 17
services/web/public/coffee/main/subscription-dashboard.coffee

@@ -1,15 +1,21 @@
 define [
 	"base"
 ], (App)->
+
+	App.controller 'SuccessfulSubscriptionController', ($scope, sixpack) ->
+		sixpack.convert 'plans-1610', () ->
+
+
 	SUBSCRIPTION_URL = "/user/subscription/update"
 
 	setupReturly = _.once ->
 		recurly?.configure window.recurlyApiKey
 	PRICES = {}
 
+
 	App.controller "CurrenyDropdownController", ($scope, MultiCurrencyPricing, $q)->
 
-		$scope.plans = MultiCurrencyPricing.plans
+		# $scope.plans = MultiCurrencyPricing.plans
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 
 		$scope.changeCurrency = (newCurrency)->
@@ -31,7 +37,7 @@ define [
 			$scope.currencyCode = MultiCurrencyPricing.currencyCode
 
 		$scope.pricing = MultiCurrencyPricing
-		$scope.plans = MultiCurrencyPricing.plans
+		# $scope.plans = MultiCurrencyPricing.plans
 		$scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
 
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
@@ -53,9 +59,9 @@ define [
 			price = ""
 
 	App.controller "ConfirmChangePlanController", ($scope, $modalInstance, $http)->
-		
+
 		$scope.confirmChangePlan = ->
-			body = 
+			body =
 				plan_code: $scope.plan.planCode
 				_csrf : window.csrfToken
 
@@ -74,7 +80,7 @@ define [
 		$scope.confirmLeaveGroup = ->
 			$scope.inflight = true
 			$http({
-				url: "/subscription/group/user", 
+				url: "/subscription/group/user",
 				method: "DELETE",
 				params: {admin_user_id: $scope.admin_id, _csrf: window.csrfToken}
 			}).success ->
@@ -87,6 +93,8 @@ define [
 
 
 	App.controller "UserSubscriptionController", ($scope, MultiCurrencyPricing, $http, sixpack, $modal) ->
+		$scope.plans = MultiCurrencyPricing.plans
+
 		freeTrialEndDate = new Date(subscription?.trial_ends_at)
 
 		sevenDaysTime = new Date()
@@ -96,6 +104,16 @@ define [
 		freeTrialExpiresUnderSevenDays = freeTrialEndDate < sevenDaysTime
 
 		$scope.view = 'overview'
+		$scope.getSuffix = (planCode) ->
+			planCode?.match(/(.*?)_(.*)/)?[2] || null
+		$scope.subscriptionSuffix = $scope.getSuffix(window?.subscription?.planCode)
+		if $scope.subscriptionSuffix == 'free_trial_7_days'
+			$scope.subscriptionSuffix = ''
+		$scope.isNextGenPlan = $scope.subscriptionSuffix in ['heron', 'ibis']
+
+		$scope.shouldShowPlan = (planCode) ->
+			$scope.getSuffix(planCode) not in ['heron', 'ibis']
+
 		isMonthlyCollab = subscription?.planCode?.indexOf("collaborator") != -1 and subscription?.planCode?.indexOf("ann") == -1
 		stillInFreeTrial = freeTrialInFuture and freeTrialExpiresUnderSevenDays
 
@@ -118,7 +136,7 @@ define [
 					$scope.studentPrice = $scope.currencySymbol + (totalPriceExTax + taxAmmount)
 
 		$scope.downgradeToStudent = ->
-			body = 
+			body =
 				plan_code: 'student'
 				_csrf : window.csrfToken
 			$scope.inflight = true
@@ -129,15 +147,13 @@ define [
 					console.log "something went wrong changing plan"
 
 		$scope.cancelSubscription = ->
-			body = 
+			body =
 				_csrf : window.csrfToken
 
 			$scope.inflight = true
 			$http.post("/user/subscription/cancel", body)
 				.success ->
-					sixpack.convert 'cancelation-options-view', ->
-						sixpack.convert 'upgrade-success-message', ->
-							location.reload()
+					location.reload()
 				.error ->
 					console.log "something went wrong changing plan"
 
@@ -151,14 +167,12 @@ define [
 			)
 
 		$scope.switchToCancelationView = ->
-			sixpack.participate 'cancelation-options-view', ['basic', 'downgrade-options'], (view, rawResponse)->
-				$scope.view = "cancelation"
-				$scope.sixpackOpt = view
+			$scope.view = "cancelation"
 
 
 
 		$scope.exendTrial = ->
-			body = 
+			body =
 				_csrf : window.csrfToken
 			$scope.inflight = true
 			$http.put("/user/subscription/extend", body)
@@ -166,6 +180,3 @@ define [
 					location.reload()
 				.error ->
 					console.log "something went wrong changing plan"
-
-
-

+ 22 - 2
services/web/public/coffee/modules/errorCatcher.coffee

@@ -9,6 +9,26 @@ app.config ['$provide', ($provide) ->
 	]
 ]
 
-# TODO: add support for an errorHttpInterceptor to catch failing ajax
-# requests as described at
+# Interceptor to check auth failures in all $http requests
 # http://bahmutov.calepin.co/catch-all-errors-in-angular-app.html
+
+app.factory 'unAuthHttpResponseInterceptor', ['$q','$location', ($q, $location) ->
+		responseError: (response) ->
+			# redirect any unauthorised or forbidden responses back to /login
+			#
+			# set disableAutoLoginRedirect:true in the http request config
+			# to disable this behaviour
+			if response.status in [401, 403] and not response.config?.disableAutoLoginRedirect
+				# for /project urls set the ?redir parameter to come back here
+				# otherwise just go to the login page
+				if window.location.pathname.match(/^\/project/)
+					window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
+				else
+					window.location = "/login"
+			# pass the response back to the original requester
+			return $q.reject(response)
+]
+
+app.config ['$httpProvider', ($httpProvider) ->
+	$httpProvider.interceptors.push 'unAuthHttpResponseInterceptor'
+]

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