diff --git a/doc/screenshot.png b/doc/screenshot.png index 1c1f339630b048780aed1e692290dada8aa23307..5fbf46bb4361354d78c186089dd3301525cdb614 100644 Binary files a/doc/screenshot.png and b/doc/screenshot.png differ diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 4f0649c427e32e1fa65ec2dff6d23ff85c7e489d..1cd9e0e6a1d206068959c729d79cdcbdaeafd70c 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -8,7 +8,7 @@ function mergeDeletedDocs(a, b) { } module.exports = ProjectEditorHandler = { - trackChangesAvailable: false, + trackChangesAvailable: true, buildProjectModelView(project, members, invites, deletedDocsFromDocstore) { let owner, ownerFeatures diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index f1bdbc29e35723440133834368a51f932d44e3ed..f4b6fade0d733586c51521b4756ca081ae8ca2fe 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -907,6 +907,7 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'track-changes', ], viewIncludes: {}, diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts index 1bf99d62ce43af4e18b9f4e12b5f6fb5b58d319c..2767b81562d7a4f24bbf958a0301343737f12bb5 100644 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts @@ -84,6 +84,7 @@ const formatUser = (user: any): any => { if (id == null) { return { + id: 'anonymous-user', email: null, name: 'Anonymous', isSelf: false, @@ -283,15 +284,19 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState { // Always include ourself, since if we submit an op, we might need to display info // about it locally before it has been flushed through the server if (user) { - tempUsers[user.id] = formatUser(user) + if (user.id) { + tempUsers[user.id] = formatUser(user) + } else { + tempUsers['anonymous-user'] = formatUser(user) + } } - for (const user of usersResponse) { if (user.id) { tempUsers[user.id] = formatUser(user) + } else { + tempUsers['anonymous-user'] = formatUser(user) } } - setUsers(tempUsers) }) .catch(error => { @@ -532,9 +537,9 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState { } }, [currentDocument, regenerateTrackChangesId, resolvedThreadIds]) - const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous' => { + const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous-user' => { if (!user) { - return 'anonymous' + return 'anonymous-user' } if (project.owner._id === user.id) { return 'member' @@ -587,7 +592,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState { const setGuestsTCState = useCallback( (newValue: boolean) => { setTrackChangesOnForGuests(newValue) - if (currentUserType() === 'guest' || currentUserType() === 'anonymous') { + if (currentUserType() === 'guest' || currentUserType() === 'anonymous-user') { setWantTrackChanges(newValue) } }, diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 853633916cccc2ae7fe983dcfb261ded35550571..689847c9374a64326ccb68d04fd035845627d804 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -177,7 +177,7 @@ function useCodeMirrorScope(view: EditorView) { if (currentDoc) { if (trackChanges) { - currentDoc.track_changes_as = userId || 'anonymous' + currentDoc.track_changes_as = userId || 'anonymous-user' } else { currentDoc.track_changes_as = null } diff --git a/services/web/modules/track-changes/app/src/TrackChangesController.js b/services/web/modules/track-changes/app/src/TrackChangesController.js new file mode 100644 index 0000000000000000000000000000000000000000..6cf3645c567e6c979cd519cd0314b9bbfc60f3e2 --- /dev/null +++ b/services/web/modules/track-changes/app/src/TrackChangesController.js @@ -0,0 +1,308 @@ +const ChatApiHandler = require('../../../../app/src/Features/Chat/ChatApiHandler') +const ChatManager = require('../../../../app/src/Features/Chat/ChatManager') +const EditorRealTimeController = require('../../../../app/src/Features/Editor/EditorRealTimeController') +const SessionManager = require('../../../../app/src/Features/Authentication/SessionManager') +const UserInfoManager = require('../../../../app/src/Features/User/UserInfoManager') +const DocstoreManager = require('../../../../app/src/Features/Docstore/DocstoreManager') +const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler') +const CollaboratorsGetter = require('../../../../app/src/Features/Collaborators/CollaboratorsGetter') +const { Project } = require('../../../../app/src/models/Project') +const pLimit = require('p-limit') + +async function _updateTCState (projectId, state, callback) { + await Project.updateOne({_id: projectId}, {track_changes: state}).exec() + callback() +} +function _transformId(doc) { + if (doc._id) { + doc.id = doc._id; + delete doc._id; + } + return doc; +} + +const TrackChangesController = { + trackChanges(req, res, next) { + const { project_id } = req.params + let state = req.body.on || req.body.on_for + if ( req.body.on_for_guests && !req.body.on ) state.__guests__ = true + + return _updateTCState(project_id, state, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'toggle-track-changes', + state + ) + return res.sendStatus(204) + } + ) + }, + acceptChanges(req, res, next) { + const { project_id, doc_id } = req.params + const change_ids = req.body.change_ids + return DocumentUpdaterHandler.acceptChanges( + project_id, + doc_id, + change_ids, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'accept-changes', + doc_id, + change_ids, + ) + return res.sendStatus(204) + } + ) + }, + async getAllRanges(req, res, next) { + const { project_id } = req.params + // FIXME: ranges are from mongodb, probably already outdated + const ranges = await DocstoreManager.promises.getAllRanges(project_id) +// frontend expects 'id', not '_id' + return res.json(ranges.map(_transformId)) + }, + async getChangesUsers(req, res, next) { + const { project_id } = req.params + const memberIds = await CollaboratorsGetter.promises.getMemberIds(project_id) + // FIXME: Does not work properly if the user is no longer a member of the project + // memberIds from DocstoreManager.getAllRanges(project_id) is not a remedy + // because ranges are not updated in real-time + const limit = pLimit(3) + const users = await Promise.all( + memberIds.map(memberId => + limit(async () => { + const user = await UserInfoManager.promises.getPersonalInfo(memberId) + return user + }) + ) + ) + users.push({_id: null}) // An anonymous user won't cause any harm +// frontend expects 'id', not '_id' + return res.json(users.map(_transformId)) + }, + getThreads(req, res, next) { + const { project_id } = req.params + return ChatApiHandler.getThreads( + project_id, + function (err, messages) { + if (err != null) { + return next(err) + } + return ChatManager.injectUserInfoIntoThreads( + messages, + function (err) { + if (err != null) { + return next(err) + } + return res.json(messages) + } + ) + } + ) + }, + sendComment(req, res, next) { + const { project_id, thread_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (user_id == null) { + const err = new Error('no logged-in user') + return next(err) + } + return ChatApiHandler.sendComment( + project_id, + thread_id, + user_id, + content, + function (err, message) { + if (err != null) { + return next(err) + } + return UserInfoManager.getPersonalInfo( + user_id, + function (err, user) { + if (err != null) { + return next(err) + } + message.user = user + EditorRealTimeController.emitToRoom( + project_id, + 'new-comment', + thread_id, message + ) + return res.sendStatus(204) + } + ) + } + ) + }, + editMessage(req, res, next) { + const { project_id, thread_id, message_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (user_id == null) { + const err = new Error('no logged-in user') + return next(err) + } + return ChatApiHandler.editMessage( + project_id, + thread_id, + message_id, + user_id, + content, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'edit-message', + thread_id, + message_id, + content + ) + return res.sendStatus(204) + } + ) + }, + deleteMessage(req, res, next) { + const { project_id, thread_id, message_id } = req.params + return ChatApiHandler.deleteMessage( + project_id, + thread_id, + message_id, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'delete-message', + thread_id, + message_id + ) + return res.sendStatus(204) + } + ) + }, + resolveThread(req, res, next) { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (user_id == null) { + const err = new Error('no logged-in user') + return next(err) + } + DocumentUpdaterHandler.resolveThread( + project_id, + doc_id, + thread_id, + user_id, + function (err, message) { + if (err != null) { + return next(err) + } + } + ) + return ChatApiHandler.resolveThread( + project_id, + thread_id, + user_id, + function (err, message) { + if (err != null) { + return next(err) + } + return UserInfoManager.getPersonalInfo( + user_id, + function (err, user) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'resolve-thread', + thread_id, + user + ) + return res.sendStatus(204) + } + ) + } + ) + }, + reopenThread(req, res, next) { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (user_id == null) { + const err = new Error('no logged-in user') + return next(err) + } + DocumentUpdaterHandler.reopenThread( + project_id, + doc_id, + thread_id, + user_id, + function (err, message) { + if (err != null) { + return next(err) + } + } + ) + return ChatApiHandler.reopenThread( + project_id, + thread_id, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'reopen-thread', + thread_id + ) + return res.sendStatus(204) + } + ) + }, + deleteThread(req, res, next) { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (user_id == null) { + const err = new Error('no logged-in user') + return next(err) + } + return DocumentUpdaterHandler.deleteThread( + project_id, + doc_id, + thread_id, + user_id, + function (err, message) { + if (err != null) { + return next(err) + } + ChatApiHandler.deleteThread( + project_id, + thread_id, + function (err, message) { + if (err != null) { + return next(err) + } + EditorRealTimeController.emitToRoom( + project_id, + 'delete-thread', + thread_id + ) + return res.sendStatus(204) + } + ) + } + ) + }, +} +module.exports = TrackChangesController diff --git a/services/web/modules/track-changes/app/src/TrackChangesRouter.js b/services/web/modules/track-changes/app/src/TrackChangesRouter.js new file mode 100644 index 0000000000000000000000000000000000000000..3791e251a1a3527d545d2cf482e8d6a2ee14c8f2 --- /dev/null +++ b/services/web/modules/track-changes/app/src/TrackChangesRouter.js @@ -0,0 +1,72 @@ +const logger = require('@overleaf/logger') +const AuthorizationMiddleware = require('../../../../app/src/Features/Authorization/AuthorizationMiddleware') +const TrackChangesController = require('./TrackChangesController') + +module.exports = { + apply(webRouter) { + logger.debug({}, 'Init track-changes router') + + webRouter.post('/project/:project_id/track_changes', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.trackChanges + ) + webRouter.post('/project/:project_id/doc/:doc_id/changes/accept', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.acceptChanges + ) + webRouter.get('/project/:project_id/ranges', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getAllRanges + ) + webRouter.get('/project/:project_id/changes/users', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getChangesUsers + ) + webRouter.get( + '/project/:project_id/threads', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getThreads + ) + webRouter.post( + '/project/:project_id/thread/:thread_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.sendComment + ) + webRouter.post( + '/project/:project_id/thread/:thread_id/messages/:message_id/edit', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.editMessage + ) + webRouter.delete( + '/project/:project_id/thread/:thread_id/messages/:message_id', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.deleteMessage + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/thread/:thread_id/resolve', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.resolveThread + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/thread/:thread_id/reopen', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.reopenThread + ) + webRouter.delete( + '/project/:project_id/doc/:doc_id/thread/:thread_id', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.deleteThread + ) + }, +} diff --git a/services/web/modules/track-changes/index.js b/services/web/modules/track-changes/index.js new file mode 100644 index 0000000000000000000000000000000000000000..aa9e6a73dad1ae15d5150ec89d486f55527fb062 --- /dev/null +++ b/services/web/modules/track-changes/index.js @@ -0,0 +1,2 @@ +const TrackChangesRouter = require('./app/src/TrackChangesRouter') +module.exports = { router : TrackChangesRouter }