diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs index e6b0af442a..7ad80fc6eb 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs @@ -3,7 +3,7 @@ import Path from 'node:path' let ProjectEditorHandler export default ProjectEditorHandler = { - trackChangesAvailable: false, + trackChangesAvailable: true, buildProjectModelView( project, diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 5375871cc7..a44e17aff3 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1081,6 +1081,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 new file mode 100644 index 0000000000..c712384163 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts @@ -0,0 +1,1659 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { isEqual, cloneDeep } from 'lodash' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import useScopeValue from '../../../../../shared/hooks/use-scope-value' +import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' +import useAbortController from '@/shared/hooks/use-abort-controller' +import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter' +import useLayoutToLeft from '@/features/ide-react/context/review-panel/hooks/useLayoutToLeft' +import { sendMB } from '@/infrastructure/event-tracking' +import { + dispatchReviewPanelLayout as handleLayoutChange, + UpdateType, +} from '@/features/source-editor/extensions/changes/change-manager' +import { useProjectContext } from '@/shared/context/project-context' +import { useLayoutContext } from '@/shared/context/layout-context' +import { useUserContext } from '@/shared/context/user-context' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' +import { useConnectionContext } from '@/features/ide-react/context/connection-context' +import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' +import { useModalsContext } from '@/features/ide-react/context/modals-context' +import { + EditorManager, + useEditorManagerContext, +} from '@/features/ide-react/context/editor-manager-context' +import { debugConsole } from '@/utils/debugging' +import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' +import RangesTracker from '@overleaf/ranges-tracker' +import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state' +import { + CommentId, + ReviewPanelCommentThreadMessage, + ReviewPanelCommentThreads, + ReviewPanelDocEntries, + SubView, + ThreadId, +} from '../../../../../../../types/review-panel/review-panel' +import { UserId } from '../../../../../../../types/user' +import { PublicAccessLevel } from '../../../../../../../types/public-access-level' +import { + DeepReadonly, + Entries, + MergeAndOverride, +} from '../../../../../../../types/utils' +import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread' +import { DocId } from '../../../../../../../types/project-settings' +import { + ReviewPanelAddCommentEntry, + ReviewPanelAggregateChangeEntry, + ReviewPanelBulkActionsEntry, + ReviewPanelChangeEntry, + ReviewPanelCommentEntry, + ReviewPanelEntry, +} from '../../../../../../../types/review-panel/entry' +import { + ReviewPanelCommentThreadMessageApi, + ReviewPanelCommentThreadsApi, +} from '../../../../../../../types/review-panel/api' +import { DateString } from '../../../../../../../types/helpers/date' +import { + Change, + CommentOperation, + EditOperation, +} from '../../../../../../../types/change' +import { RangesTrackerWithResolvedThreadIds } from '@/features/ide-react/editor/document-container' +import getMeta from '@/utils/meta' +import { useEditorContext } from '@/shared/context/editor-context' +import { getHueForUserId } from '@/shared/utils/colors' + +const dispatchReviewPanelEvent = (type: string, payload?: any) => { + window.dispatchEvent( + new CustomEvent('review-panel:event', { + detail: { type, payload }, + }) + ) +} + +const formatUser = (user: any): any => { + let isSelf, name + const id = + (user != null ? user._id : undefined) || + (user != null ? user.id : undefined) + + if (id == null) { + return { + id: 'anonymous-user', + email: null, + name: 'Anonymous', + isSelf: false, + hue: getHueForUserId(), + avatar_text: 'A', + } + } + if (id === getMeta('ol-user_id')) { + name = 'You' + isSelf = true + } else { + name = [user.first_name, user.last_name] + .filter(n => n != null && n !== '') + .join(' ') + if (name === '') { + name = + (user.email != null ? user.email.split('@')[0] : undefined) || 'Unknown' + } + isSelf = false + } + return { + id, + email: user.email, + name, + isSelf, + hue: getHueForUserId(id), + avatar_text: [user.first_name, user.last_name] + .filter(n => n != null) + .map(n => n[0]) + .join(''), + } +} + +const formatComment = ( + comment: ReviewPanelCommentThreadMessageApi +): ReviewPanelCommentThreadMessage => { + const commentTyped = comment as unknown as ReviewPanelCommentThreadMessage + commentTyped.user = formatUser(comment.user) + commentTyped.timestamp = new Date(comment.timestamp) + return commentTyped +} + +function useReviewPanelState(): ReviewPanel.ReviewPanelState { + const { t } = useTranslation() + const { reviewPanelOpen, setReviewPanelOpen, setMiniReviewPanelVisible } = + useLayoutContext() + const { projectId } = useIdeReactContext() + const project = useProjectContext() + const user = useUserContext() + const { socket } = useConnectionContext() + const { + features: { trackChangesVisible, trackChanges }, + } = project + const { isRestrictedTokenMember } = useEditorContext() + const { + openDocWithId, + currentDocument, + currentDocumentId, + wantTrackChanges, + setWantTrackChanges, + } = useEditorManagerContext() as MergeAndOverride< + EditorManager, + { currentDocumentId: DocId } + > + // TODO permissions to be removed from the review panel context. It currently acts just as a proxy. + const permissions = usePermissionsContext() + const { showGenericMessageModal } = useModalsContext() + const addCommentEmitter = useScopeEventEmitter('comment:start_adding') + + const layoutToLeft = useLayoutToLeft('.ide-react-editor-panel') + const [subView, setSubView] = + useState>('cur_file') + const [isOverviewLoading, setIsOverviewLoading] = + useState>(false) + // All selected changes. If an aggregated change (insertion + deletion) is selected, the two ids + // will be present. The length of this array will differ from the count below (see explanation). + const selectedEntryIds = useRef([]) + // A count of user-facing selected changes. An aggregated change (insertion + deletion) will count + // as only one. + const [nVisibleSelectedChanges, setNVisibleSelectedChanges] = + useState>(0) + const [collapsed, setCollapsed] = usePersistedState< + ReviewPanel.Value<'collapsed'> + >(`docs_collapsed_state:${projectId}`, {}, false, true) + const [commentThreads, setCommentThreads] = useState< + ReviewPanel.Value<'commentThreads'> + >({}) + const [entries, setEntries] = useState>({}) + const [users, setUsers] = useScopeValue>('users') + const [resolvedComments, setResolvedComments] = useState< + ReviewPanel.Value<'resolvedComments'> + >({}) + + const [shouldCollapse, setShouldCollapse] = + useState>(true) + const [lineHeight, setLineHeight] = + useState>(0) + + const [formattedProjectMembers, setFormattedProjectMembers] = useState< + ReviewPanel.Value<'formattedProjectMembers'> + >({}) + const [trackChangesState, setTrackChangesState] = useState< + ReviewPanel.Value<'trackChangesState'> + >({}) + const [trackChangesOnForEveryone, setTrackChangesOnForEveryone] = + useState>(false) + const [trackChangesOnForGuests, setTrackChangesOnForGuests] = + useState>(false) + const [trackChangesForGuestsAvailable, setTrackChangesForGuestsAvailable] = + useState>(false) + + const [resolvedThreadIds, setResolvedThreadIds] = useState< + Record + >({}) + + const [loadingThreads, setLoadingThreads] = + useScopeValue('loadingThreads') + + const loadThreadsController = useAbortController() + const threadsLoadedOnceRef = useRef(false) + const loadingThreadsInProgressRef = useRef(false) + const ensureThreadsAreLoaded = useCallback(() => { + if (threadsLoadedOnceRef.current) { + // We get any updates in real time so only need to load them once. + return + } + threadsLoadedOnceRef.current = true + loadingThreadsInProgressRef.current = true + + return getJSON(`/project/${projectId}/threads`, { + signal: loadThreadsController.signal, + }) + .then(threads => { + setLoadingThreads(false) + const tempResolvedThreadIds: typeof resolvedThreadIds = {} + const threadsEntries = Object.entries(threads) as [ + [ + ThreadId, + MergeAndOverride< + ReviewPanelCommentThread, + ReviewPanelCommentThreadsApi[ThreadId] + >, + ], + ] + for (const [threadId, thread] of threadsEntries) { + for (const comment of thread.messages) { + formatComment(comment) + } + if (thread.resolved_by_user) { + thread.resolved_by_user = formatUser(thread.resolved_by_user) + tempResolvedThreadIds[threadId] = true + } + } + setResolvedThreadIds(tempResolvedThreadIds) + setCommentThreads(threads as unknown as ReviewPanelCommentThreads) + + dispatchReviewPanelEvent('loaded_threads') + handleLayoutChange({ async: true }) + + return { + resolvedThreadIds: tempResolvedThreadIds, + commentThreads: threads, + } + }) + .catch(debugConsole.error) + .finally(() => { + loadingThreadsInProgressRef.current = false + }) + }, [loadThreadsController.signal, projectId, setLoadingThreads]) + + const rangesTrackers = useRef< + Record + >({}) + const refreshingRangeUsers = useRef(false) + const refreshedForUserIds = useRef(new Set()) + const refreshChangeUsers = useCallback( + (userId: UserId | null) => { + if (userId != null) { + if (refreshedForUserIds.current.has(userId)) { + // We've already tried to refresh to get this user id, so stop it looping + return + } + refreshedForUserIds.current.add(userId) + } + + // Only do one refresh at once + if (refreshingRangeUsers.current) { + return + } + refreshingRangeUsers.current = true + + getJSON(`/project/${projectId}/changes/users`) + .then(usersResponse => { + refreshingRangeUsers.current = false + const tempUsers = {} as ReviewPanel.Value<'users'> + // Always include ourself, since if we submit an op, we might need to display info + // about it locally before it has been flushed through the server + if (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 => { + refreshingRangeUsers.current = false + debugConsole.error(error) + }) + }, + [projectId, setUsers, user] + ) + + const getChangeTracker = useCallback( + (docId: DocId) => { + if (!rangesTrackers.current[docId]) { + const rangesTracker = new RangesTracker([], []) + ;( + rangesTracker as RangesTrackerWithResolvedThreadIds + ).resolvedThreadIds = { ...resolvedThreadIds } + rangesTrackers.current[docId] = + rangesTracker as RangesTrackerWithResolvedThreadIds + } + return rangesTrackers.current[docId]! + }, + [resolvedThreadIds] + ) + + const getDocEntries = useCallback( + (docId: DocId) => { + return entries[docId] ?? ({} as ReviewPanelDocEntries) + }, + [entries] + ) + + const getDocResolvedComments = useCallback( + (docId: DocId) => { + return resolvedComments[docId] ?? ({} as ReviewPanelDocEntries) + }, + [resolvedComments] + ) + + const getThread = useCallback( + (threadId: ThreadId) => { + return ( + commentThreads[threadId] ?? + ({ messages: [] } as ReviewPanelCommentThread) + ) + }, + [commentThreads] + ) + + const updateEntries = useCallback( + async (docId: DocId) => { + const rangesTracker = getChangeTracker(docId) + const docEntries = cloneDeep(getDocEntries(docId)) + const docResolvedComments = cloneDeep(getDocResolvedComments(docId)) + // Assume we'll delete everything until we see it, then we'll remove it from this object + const deleteChanges = new Set() + + for (const [id, change] of Object.entries(docEntries) as Entries< + typeof docEntries + >) { + if ( + 'entry_ids' in change && + id !== 'add-comment' && + id !== 'bulk-actions' + ) { + for (const entryId of change.entry_ids) { + deleteChanges.add(entryId) + } + } + } + for (const [, change] of Object.entries(docResolvedComments) as Entries< + typeof docResolvedComments + >) { + if ('entry_ids' in change) { + for (const entryId of change.entry_ids) { + deleteChanges.add(entryId) + } + } + } + + let potentialAggregate = false + let prevInsertion = null + + for (const change of rangesTracker.changes as any[]) { + if ( + potentialAggregate && + change.op.d && + change.op.p === prevInsertion.op.p + prevInsertion.op.i.length && + change.metadata.user_id === prevInsertion.metadata.user_id + ) { + // An actual aggregate op. + const aggregateChangeEntries = docEntries as Record< + string, + ReviewPanelAggregateChangeEntry + > + aggregateChangeEntries[prevInsertion.id].type = 'aggregate-change' + aggregateChangeEntries[prevInsertion.id].metadata.replaced_content = + change.op.d + aggregateChangeEntries[prevInsertion.id].entry_ids.push(change.id) + } else { + if (docEntries[change.id] == null) { + docEntries[change.id] = {} as ReviewPanelEntry + } + deleteChanges.delete(change.id) + const newEntry: Partial = { + type: change.op.i ? 'insert' : 'delete', + entry_ids: [change.id], + content: change.op.i || change.op.d, + offset: change.op.p, + metadata: change.metadata, + } + for (const [key, value] of Object.entries(newEntry) as Entries< + typeof newEntry + >) { + const entriesTyped = docEntries[change.id] as Record + entriesTyped[key] = value + } + } + + if (change.op.i) { + potentialAggregate = true + prevInsertion = change + } else { + potentialAggregate = false + prevInsertion = null + } + + if (!users[change.metadata.user_id]) { + if (!isRestrictedTokenMember) { + refreshChangeUsers(change.metadata.user_id) + } + } + } + + let localResolvedThreadIds = resolvedThreadIds + + if (!isRestrictedTokenMember && rangesTracker.comments.length > 0) { + const threadsLoadResult = await ensureThreadsAreLoaded() + if (threadsLoadResult?.resolvedThreadIds) { + localResolvedThreadIds = threadsLoadResult.resolvedThreadIds + } + } else if (loadingThreads) { + // ensure that tracked changes are highlighted even if no comments are loaded + setLoadingThreads(false) + dispatchReviewPanelEvent('loaded_threads') + } + + if (!loadingThreadsInProgressRef.current) { + for (const comment of rangesTracker.comments) { + const commentId = comment.id as ThreadId + deleteChanges.delete(commentId) + + let newComment: any + if (localResolvedThreadIds[comment.op.t]) { + docResolvedComments[commentId] ??= {} as ReviewPanelCommentEntry + newComment = docResolvedComments[commentId] + delete docEntries[commentId] + } else { + docEntries[commentId] ??= {} as ReviewPanelEntry + newComment = docEntries[commentId] + delete docResolvedComments[commentId] + } + + newComment.type = 'comment' + newComment.thread_id = comment.op.t + newComment.entry_ids = [comment.id] + newComment.content = comment.op.c + newComment.offset = comment.op.p + } + } + + deleteChanges.forEach(changeId => { + delete docEntries[changeId] + delete docResolvedComments[changeId] + }) + + setEntries(prev => { + return isEqual(prev[docId], docEntries) + ? prev + : { ...prev, [docId]: docEntries } + }) + setResolvedComments(prev => { + return isEqual(prev[docId], docResolvedComments) + ? prev + : { ...prev, [docId]: docResolvedComments } + }) + + return docEntries + }, + [ + getChangeTracker, + getDocEntries, + getDocResolvedComments, + refreshChangeUsers, + resolvedThreadIds, + users, + ensureThreadsAreLoaded, + loadingThreads, + setLoadingThreads, + isRestrictedTokenMember, + ] + ) + + const regenerateTrackChangesId = useCallback( + (doc: typeof currentDocument) => { + if (doc) { + const currentChangeTracker = getChangeTracker(doc.doc_id as DocId) + const oldId = currentChangeTracker.getIdSeed() + const newId = RangesTracker.generateIdSeed() + currentChangeTracker.setIdSeed(newId) + doc.setTrackChangesIdSeeds({ pending: newId, inflight: oldId }) + } + }, + [getChangeTracker] + ) + + useEffect(() => { + if (!currentDocument) { + return + } + // The open doc range tracker is kept up to date in real-time so + // replace any outdated info with this + const rangesTracker = currentDocument.ranges! + ;(rangesTracker as RangesTrackerWithResolvedThreadIds).resolvedThreadIds = { + ...resolvedThreadIds, + } + rangesTrackers.current[currentDocument.doc_id as DocId] = + rangesTracker as RangesTrackerWithResolvedThreadIds + currentDocument.on('flipped_pending_to_inflight', () => + regenerateTrackChangesId(currentDocument) + ) + regenerateTrackChangesId(currentDocument) + + return () => { + currentDocument.off('flipped_pending_to_inflight') + } + }, [currentDocument, regenerateTrackChangesId, resolvedThreadIds]) + + const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous-user' => { + if (!user) { + return 'anonymous-user' + } + if (project.owner._id === user.id) { + return 'member' + } + for (const member of project.members as any[]) { + if (member._id === user.id) { + return 'member' + } + } + return 'guest' + }, [project.members, project.owner, user]) + + const applyClientTrackChangesStateToServer = useCallback( + ( + trackChangesOnForEveryone: boolean, + trackChangesOnForGuests: boolean, + trackChangesState: ReviewPanel.Value<'trackChangesState'> + ) => { + const data: { + on?: boolean + on_for?: Record + on_for_guests?: boolean + } = {} + if (trackChangesOnForEveryone) { + data.on = true + } else { + data.on_for = {} + const entries = Object.entries(trackChangesState) as Array< + [ + UserId, + NonNullable< + (typeof trackChangesState)[keyof typeof trackChangesState] + >, + ] + > + for (const [userId, { value }] of entries) { + data.on_for[userId] = value + } + if (trackChangesOnForGuests) { + data.on_for_guests = true + } + } + postJSON(`/project/${projectId}/track_changes`, { + body: data, + }).catch(debugConsole.error) + }, + [projectId] + ) + + const setGuestsTCState = useCallback( + (newValue: boolean) => { + setTrackChangesOnForGuests(newValue) + if (currentUserType() === 'guest' || currentUserType() === 'anonymous-user') { + setWantTrackChanges(newValue) + } + }, + [currentUserType, setWantTrackChanges] + ) + + const setUserTCState = useCallback( + ( + trackChangesState: DeepReadonly>, + userId: UserId, + newValue: boolean, + isLocal = false + ) => { + const newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = { + ...trackChangesState, + } + const state = + newTrackChangesState[userId] ?? + ({} as NonNullable<(typeof newTrackChangesState)[UserId]>) + newTrackChangesState[userId] = state + + if (state.syncState == null || state.syncState === 'synced') { + state.value = newValue + state.syncState = 'synced' + } else if (state.syncState === 'pending' && state.value === newValue) { + state.syncState = 'synced' + } else if (isLocal) { + state.value = newValue + state.syncState = 'pending' + } + + setTrackChangesState(newTrackChangesState) + + if (userId === user.id) { + setWantTrackChanges(newValue) + } + + return newTrackChangesState + }, + [setWantTrackChanges, user.id] + ) + + const setEveryoneTCState = useCallback( + (newValue: boolean, isLocal = false) => { + setTrackChangesOnForEveryone(newValue) + let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = { + ...trackChangesState, + } + for (const member of project.members as any[]) { + newTrackChangesState = setUserTCState( + newTrackChangesState, + member._id, + newValue, + isLocal + ) + } + setGuestsTCState(newValue) + + newTrackChangesState = setUserTCState( + newTrackChangesState, + project.owner._id, + newValue, + isLocal + ) + + return { trackChangesState: newTrackChangesState } + }, + [ + project.members, + project.owner._id, + setGuestsTCState, + setUserTCState, + trackChangesState, + ] + ) + + const toggleTrackChangesForEveryone = useCallback< + ReviewPanel.UpdaterFn<'toggleTrackChangesForEveryone'> + >( + (onForEveryone: boolean) => { + const { trackChangesState } = setEveryoneTCState(onForEveryone, true) + setGuestsTCState(onForEveryone) + applyClientTrackChangesStateToServer( + onForEveryone, + onForEveryone, + trackChangesState + ) + }, + [applyClientTrackChangesStateToServer, setEveryoneTCState, setGuestsTCState] + ) + + const toggleTrackChangesForGuests = useCallback< + ReviewPanel.UpdaterFn<'toggleTrackChangesForGuests'> + >( + (onForGuests: boolean) => { + setGuestsTCState(onForGuests) + applyClientTrackChangesStateToServer( + trackChangesOnForEveryone, + onForGuests, + trackChangesState + ) + }, + [ + applyClientTrackChangesStateToServer, + setGuestsTCState, + trackChangesOnForEveryone, + trackChangesState, + ] + ) + + const toggleTrackChangesForUser = useCallback< + ReviewPanel.UpdaterFn<'toggleTrackChangesForUser'> + >( + (onForUser: boolean, userId: UserId) => { + const newTrackChangesState = setUserTCState( + trackChangesState, + userId, + onForUser, + true + ) + applyClientTrackChangesStateToServer( + trackChangesOnForEveryone, + trackChangesOnForGuests, + newTrackChangesState + ) + }, + [ + applyClientTrackChangesStateToServer, + setUserTCState, + trackChangesOnForEveryone, + trackChangesOnForGuests, + trackChangesState, + ] + ) + + const applyTrackChangesStateToClient = useCallback( + (state: boolean | Record) => { + if (typeof state === 'boolean') { + setEveryoneTCState(state) + setGuestsTCState(state) + } else { + setTrackChangesOnForEveryone(false) + // TODO + // @ts-ignore + setGuestsTCState(state.__guests__ === true) + + let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = { + ...trackChangesState, + } + for (const member of project.members as any[]) { + newTrackChangesState = setUserTCState( + newTrackChangesState, + member._id, + !!state[member._id] + ) + } + newTrackChangesState = setUserTCState( + newTrackChangesState, + project.owner._id, + !!state[project.owner._id] + ) + return newTrackChangesState + } + }, + [ + project.members, + project.owner._id, + setEveryoneTCState, + setGuestsTCState, + setUserTCState, + trackChangesState, + ] + ) + + const setGuestFeatureBasedOnProjectAccessLevel = ( + projectPublicAccessLevel?: PublicAccessLevel + ) => { + setTrackChangesForGuestsAvailable(projectPublicAccessLevel === 'tokenBased') + } + + useEffect(() => { + setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel) + }, [project.publicAccessLevel]) + + useEffect(() => { + if ( + trackChangesForGuestsAvailable || + !trackChangesOnForGuests || + trackChangesOnForEveryone + ) { + return + } + + // Overrides guest setting + toggleTrackChangesForGuests(false) + }, [ + toggleTrackChangesForGuests, + trackChangesForGuestsAvailable, + trackChangesOnForEveryone, + trackChangesOnForGuests, + ]) + + const projectJoinedEffectExecuted = useRef(false) + useEffect(() => { + if (!projectJoinedEffectExecuted.current) { + projectJoinedEffectExecuted.current = true + requestAnimationFrame(() => { + if (trackChanges) { + applyTrackChangesStateToClient(project.trackChangesState) + } else { + applyTrackChangesStateToClient(false) + } + setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel) + }) + } + }, [ + applyTrackChangesStateToClient, + trackChanges, + project.publicAccessLevel, + project.trackChangesState, + ]) + + useEffect(() => { + setFormattedProjectMembers(prevState => { + const tempFormattedProjectMembers: typeof prevState = {} + if (project.owner) { + tempFormattedProjectMembers[project.owner._id] = formatUser( + project.owner + ) + } + const members = project.members ?? [] + for (const member of members) { + if (member.privileges === 'readAndWrite') { + if (!trackChangesState[member._id]) { + // An added member will have track changes enabled if track changes is on for everyone + setUserTCState( + trackChangesState, + member._id, + trackChangesOnForEveryone, + true + ) + } + tempFormattedProjectMembers[member._id] = formatUser(member) + } + } + return tempFormattedProjectMembers + }) + }, [ + project.members, + project.owner, + setUserTCState, + trackChangesOnForEveryone, + trackChangesState, + ]) + + useSocketListener( + socket, + 'toggle-track-changes', + applyTrackChangesStateToClient + ) + + const gotoEntry = useCallback( + (docId: DocId, entryOffset: number) => { + openDocWithId(docId, { gotoOffset: entryOffset }) + }, + [openDocWithId] + ) + + const view = reviewPanelOpen ? subView : 'mini' + + const toggleReviewPanel = useCallback(() => { + if (!trackChangesVisible) { + return + } + setReviewPanelOpen(!reviewPanelOpen) + sendMB('rp-toggle-panel', { + value: !reviewPanelOpen, + }) + }, [reviewPanelOpen, setReviewPanelOpen, trackChangesVisible]) + + const onCommentResolved = useCallback( + (threadId: ThreadId, user: any) => { + setCommentThreads(prevState => { + const thread = { ...getThread(threadId) } + thread.resolved = true + thread.resolved_by_user = formatUser(user) + thread.resolved_at = new Date().toISOString() as DateString + return { ...prevState, [threadId]: thread } + }) + setResolvedThreadIds(prevState => ({ ...prevState, [threadId]: true })) + setTimeout(() => { + dispatchReviewPanelEvent('comment:resolve_threads', [threadId]) + }) + }, + [getThread] + ) + + const resolveComment = useCallback( + (docId: DocId, entryId: ThreadId) => { + const docEntries = getDocEntries(docId) + const entry = docEntries[entryId] as ReviewPanelCommentEntry + + setEntries(prevState => ({ + ...prevState, + [docId]: { + ...prevState[docId], + [entryId]: { + ...prevState[docId][entryId], + focused: false, + }, + }, + })) + + postJSON( + `/project/${projectId}/doc/${docId}/thread/${entry.thread_id}/resolve` + ) + onCommentResolved(entry.thread_id, user) + sendMB('rp-comment-resolve', { view }) + }, + [getDocEntries, onCommentResolved, projectId, user, view] + ) + + const onCommentReopened = useCallback( + (threadId: ThreadId) => { + setCommentThreads(prevState => { + const { + resolved: _1, + resolved_by_user: _2, + resolved_at: _3, + ...thread + } = getThread(threadId) + return { ...prevState, [threadId]: thread } + }) + setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => { + return resolvedThreadIds + }) + setTimeout(() => { + dispatchReviewPanelEvent('comment:unresolve_thread', threadId) + }) + }, + [getThread] + ) + + const unresolveComment = useCallback( + (docId: DocId, threadId: ThreadId) => { + onCommentReopened(threadId) + const url = `/project/${projectId}/doc/${docId}/thread/${threadId}/reopen` + postJSON(url).catch(debugConsole.error) + sendMB('rp-comment-reopen') + }, + [onCommentReopened, projectId] + ) + + const onThreadDeleted = useCallback((threadId: ThreadId) => { + setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => { + return resolvedThreadIds + }) + setCommentThreads(({ [threadId]: _, ...commentThreads }) => { + return commentThreads + }) + dispatchReviewPanelEvent('comment:remove', threadId) + }, []) + + const deleteThread = useCallback( + (docId: DocId, threadId: ThreadId) => { + onThreadDeleted(threadId) + deleteJSON(`/project/${projectId}/doc/${docId}/thread/${threadId}`).catch( + debugConsole.error + ) + sendMB('rp-comment-delete') + }, + [onThreadDeleted, projectId] + ) + + const onCommentEdited = useCallback( + (threadId: ThreadId, commentId: CommentId, content: string) => { + setCommentThreads(prevState => { + const thread = { ...getThread(threadId) } + thread.messages = thread.messages.map(message => { + return message.id === commentId ? { ...message, content } : message + }) + return { ...prevState, [threadId]: thread } + }) + }, + [getThread] + ) + + const saveEdit = useCallback( + (threadId: ThreadId, commentId: CommentId, content: string) => { + const url = `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit` + postJSON(url, { body: { content } }).catch(debugConsole.error) + handleLayoutChange({ async: true }) + }, + [projectId] + ) + + const onCommentDeleted = useCallback( + (threadId: ThreadId, commentId: CommentId) => { + setCommentThreads(prevState => { + const thread = { ...getThread(threadId) } + thread.messages = thread.messages.filter(m => m.id !== commentId) + return { ...prevState, [threadId]: thread } + }) + }, + [getThread] + ) + + const deleteComment = useCallback( + (threadId: ThreadId, commentId: CommentId) => { + onCommentDeleted(threadId, commentId) + deleteJSON( + `/project/${projectId}/thread/${threadId}/messages/${commentId}` + ).catch(debugConsole.error) + handleLayoutChange({ async: true }) + }, + [onCommentDeleted, projectId] + ) + + const doAcceptChanges = useCallback( + (entryIds: ThreadId[]) => { + const url = `/project/${projectId}/doc/${currentDocumentId}/changes/accept` + postJSON(url, { body: { change_ids: entryIds } }).catch( + debugConsole.error + ) + dispatchReviewPanelEvent('changes:accept', entryIds) + }, + [currentDocumentId, projectId] + ) + + const acceptChanges = useCallback( + (entryIds: ThreadId[]) => { + doAcceptChanges(entryIds) + sendMB('rp-changes-accepted', { view }) + }, + [doAcceptChanges, view] + ) + + const doRejectChanges = useCallback((entryIds: ThreadId[]) => { + dispatchReviewPanelEvent('changes:reject', entryIds) + }, []) + + const rejectChanges = useCallback( + (entryIds: ThreadId[]) => { + doRejectChanges(entryIds) + sendMB('rp-changes-rejected', { view }) + }, + [doRejectChanges, view] + ) + + const bulkAcceptActions = useCallback(() => { + doAcceptChanges(selectedEntryIds.current) + sendMB('rp-bulk-accept', { view, nEntries: nVisibleSelectedChanges }) + }, [doAcceptChanges, nVisibleSelectedChanges, view]) + + const bulkRejectActions = useCallback(() => { + doRejectChanges(selectedEntryIds.current) + sendMB('rp-bulk-reject', { view, nEntries: nVisibleSelectedChanges }) + }, [doRejectChanges, nVisibleSelectedChanges, view]) + + const refreshRanges = useCallback(() => { + type Doc = { + id: DocId + ranges: { + comments?: Change[] + changes?: Change[] + } + } + + return getJSON(`/project/${projectId}/ranges`) + .then(docs => { + setCollapsed(prevState => { + const collapsed = { ...prevState } + docs.forEach(doc => { + if (collapsed[doc.id] == null) { + collapsed[doc.id] = false + } + }) + return collapsed + }) + + docs.forEach(async doc => { + if (doc.id !== currentDocumentId) { + // this is kept up to date in real-time, don't overwrite + const rangesTracker = getChangeTracker(doc.id) + rangesTracker.comments = doc.ranges?.comments ?? [] + rangesTracker.changes = doc.ranges?.changes ?? [] + } + }) + + return Promise.all(docs.map(doc => updateEntries(doc.id))) + }) + .catch(debugConsole.error) + }, [ + currentDocumentId, + getChangeTracker, + projectId, + setCollapsed, + updateEntries, + ]) + + const handleSetSubview = useCallback((subView: SubView) => { + setSubView(subView) + sendMB('rp-subview-change', { subView }) + }, []) + + const submitReply = useCallback( + (threadId: ThreadId, replyContent: string) => { + const url = `/project/${projectId}/thread/${threadId}/messages` + postJSON(url, { body: { content: replyContent } }).catch(() => { + showGenericMessageModal( + t('error_submitting_comment'), + t('comment_submit_error') + ) + }) + + const trackingMetadata = { + view, + size: replyContent.length, + thread: threadId, + } + + setCommentThreads(prevState => ({ + ...prevState, + [threadId]: { ...getThread(threadId), submitting: true }, + })) + handleLayoutChange({ async: true }) + sendMB('rp-comment-reply', trackingMetadata) + }, + [getThread, projectId, showGenericMessageModal, t, view] + ) + + // TODO `submitNewComment` is partially localized in the `add-comment-entry` component. + const submitNewComment = useCallback( + (content: string) => { + if (!content) { + return + } + + const entries = getDocEntries(currentDocumentId) + const addCommentEntry = entries['add-comment'] as + | ReviewPanelAddCommentEntry + | undefined + + if (!addCommentEntry) { + return + } + + const { offset, length } = addCommentEntry + const threadId = RangesTracker.generateId() as ThreadId + setCommentThreads(prevState => ({ + ...prevState, + [threadId]: { ...getThread(threadId), submitting: true }, + })) + + const url = `/project/${projectId}/thread/${threadId}/messages` + postJSON(url, { body: { content } }) + .then(() => { + dispatchReviewPanelEvent('comment:add', { threadId, offset, length }) + handleLayoutChange({ async: true }) + sendMB('rp-new-comment', { size: content.length }) + }) + .catch(() => { + showGenericMessageModal( + t('error_submitting_comment'), + t('comment_submit_error') + ) + }) + }, + [ + currentDocumentId, + getDocEntries, + getThread, + projectId, + showGenericMessageModal, + t, + ] + ) + + const [isAddingComment, setIsAddingComment] = useState(false) + const [navHeight, setNavHeight] = useState(0) + const [toolbarHeight, setToolbarHeight] = useState(0) + const [layoutSuspended, setLayoutSuspended] = useState(false) + const [unsavedComment, setUnsavedComment] = useState('') + + useEffect(() => { + if (!trackChangesVisible) { + setReviewPanelOpen(false) + } + }, [trackChangesVisible, setReviewPanelOpen]) + + const hasEntries = useMemo(() => { + const docEntries = getDocEntries(currentDocumentId) + const permEntriesCount = Object.keys(docEntries).filter(key => { + return !['add-comment', 'bulk-actions'].includes(key) + }).length + return permEntriesCount > 0 && trackChangesVisible + }, [currentDocumentId, getDocEntries, trackChangesVisible]) + + useEffect(() => { + setMiniReviewPanelVisible(!reviewPanelOpen && !!hasEntries) + }, [reviewPanelOpen, hasEntries, setMiniReviewPanelVisible]) + + // listen for events from the CodeMirror 6 track changes extension + useEffect(() => { + const toggleTrackChangesFromKbdShortcut = () => { + const userId = user.id + if (trackChangesVisible && trackChanges && userId) { + const state = trackChangesState[userId] + if (state) { + toggleTrackChangesForUser(!state.value, userId) + } + } + } + + const editorLineHeightChanged = (payload: typeof lineHeight) => { + setLineHeight(payload) + handleLayoutChange() + } + + const editorTrackChangesChanged = async () => { + const tempEntries = cloneDeep(await updateEntries(currentDocumentId)) + + // `tempEntries` would be mutated + dispatchReviewPanelEvent('recalculate-screen-positions', { + entries: tempEntries, + updateType: 'trackedChangesChange', + }) + + // The state should be updated after dispatching the 'recalculate-screen-positions' + // event as `tempEntries` will be mutated + setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries })) + handleLayoutChange() + } + + const editorTrackChangesVisibilityChanged = () => { + handleLayoutChange({ async: true, animate: false }) + } + + const editorFocusChanged = ( + selectionOffsetStart: number, + selectionOffsetEnd: number, + selection: boolean, + updateType: UpdateType + ) => { + let tempEntries = cloneDeep(getDocEntries(currentDocumentId)) + // All selected changes will be added to this array. + selectedEntryIds.current = [] + // Count of user-visible changes, i.e. an aggregated change will count as one. + let tempNVisibleSelectedChanges = 0 + + const offset = selectionOffsetStart + const length = selectionOffsetEnd - selectionOffsetStart + + // Recreate the add comment and bulk actions entries only when + // necessary. This is to avoid the UI thinking that these entries have + // changed and getting into an infinite loop. + if (selection) { + const existingAddComment = tempEntries[ + 'add-comment' + ] as ReviewPanelAddCommentEntry + if ( + !existingAddComment || + existingAddComment.offset !== offset || + existingAddComment.length !== length + ) { + tempEntries['add-comment'] = { + type: 'add-comment', + offset, + length, + } as ReviewPanelAddCommentEntry + } + const existingBulkActions = tempEntries[ + 'bulk-actions' + ] as ReviewPanelBulkActionsEntry + if ( + !existingBulkActions || + existingBulkActions.offset !== offset || + existingBulkActions.length !== length + ) { + tempEntries['bulk-actions'] = { + type: 'bulk-actions', + offset, + length, + } as ReviewPanelBulkActionsEntry + } + } else { + delete (tempEntries as Partial)['add-comment'] + delete (tempEntries as Partial)['bulk-actions'] + } + + for (const [key, entry] of Object.entries(tempEntries) as Entries< + typeof tempEntries + >) { + let isChangeEntryAndWithinSelection = false + if (entry.type === 'comment' && !resolvedThreadIds[entry.thread_id]) { + tempEntries = { + ...tempEntries, + [key]: { + ...tempEntries[key], + focused: + entry.offset <= selectionOffsetStart && + selectionOffsetStart <= entry.offset + entry.content.length, + }, + } + } else if ( + entry.type === 'insert' || + entry.type === 'aggregate-change' + ) { + isChangeEntryAndWithinSelection = + entry.offset >= selectionOffsetStart && + entry.offset + entry.content.length <= selectionOffsetEnd + tempEntries = { + ...tempEntries, + [key]: { + ...tempEntries[key], + focused: + entry.offset <= selectionOffsetStart && + selectionOffsetStart <= entry.offset + entry.content.length, + }, + } + } else if (entry.type === 'delete') { + isChangeEntryAndWithinSelection = + selectionOffsetStart <= entry.offset && + entry.offset <= selectionOffsetEnd + tempEntries = { + ...tempEntries, + [key]: { + ...tempEntries[key], + focused: entry.offset === selectionOffsetStart, + }, + } + } else if ( + ['add-comment', 'bulk-actions'].includes(entry.type) && + selection + ) { + tempEntries = { + ...tempEntries, + [key]: { ...tempEntries[key], focused: true }, + } + } + if (isChangeEntryAndWithinSelection) { + const entryIds = 'entry_ids' in entry ? entry.entry_ids : [] + for (const entryId of entryIds) { + selectedEntryIds.current.push(entryId) + } + tempNVisibleSelectedChanges++ + } + } + + // `tempEntries` would be mutated + dispatchReviewPanelEvent('recalculate-screen-positions', { + entries: tempEntries, + updateType, + }) + + // The state should be updated after dispatching the 'recalculate-screen-positions' + // event as `tempEntries` will be mutated + setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries })) + setNVisibleSelectedChanges(tempNVisibleSelectedChanges) + + handleLayoutChange() + } + + const addNewCommentFromKbdShortcut = () => { + if (!trackChangesVisible) { + return + } + dispatchReviewPanelEvent('comment:select_line') + + if (!reviewPanelOpen) { + toggleReviewPanel() + } + handleLayoutChange({ async: true }) + addCommentEmitter() + } + + const handleEditorEvents = (e: Event) => { + const event = e as CustomEvent + const { type, payload } = event.detail + + switch (type) { + case 'line-height': { + editorLineHeightChanged(payload) + break + } + + case 'track-changes:changed': { + editorTrackChangesChanged() + break + } + + case 'track-changes:visibility_changed': { + editorTrackChangesVisibilityChanged() + break + } + + case 'focus:changed': { + const { from, to, empty, updateType } = payload + editorFocusChanged(from, to, !empty, updateType) + break + } + + case 'add-new-comment': { + addNewCommentFromKbdShortcut() + break + } + + case 'toggle-track-changes': { + toggleTrackChangesFromKbdShortcut() + break + } + + case 'toggle-review-panel': { + toggleReviewPanel() + break + } + } + } + + window.addEventListener('editor:event', handleEditorEvents) + + return () => { + window.removeEventListener('editor:event', handleEditorEvents) + } + }, [ + addCommentEmitter, + currentDocumentId, + getDocEntries, + resolvedThreadIds, + reviewPanelOpen, + toggleReviewPanel, + toggleTrackChangesForUser, + trackChanges, + trackChangesState, + trackChangesVisible, + updateEntries, + user.id, + ]) + + useSocketListener(socket, 'reopen-thread', onCommentReopened) + useSocketListener(socket, 'delete-thread', onThreadDeleted) + useSocketListener(socket, 'resolve-thread', onCommentResolved) + useSocketListener(socket, 'edit-message', onCommentEdited) + useSocketListener(socket, 'delete-message', onCommentDeleted) + useSocketListener( + socket, + 'accept-changes', + useCallback( + (docId: DocId, entryIds: ThreadId[]) => { + if (docId !== currentDocumentId) { + getChangeTracker(docId).removeChangeIds(entryIds) + } else { + dispatchReviewPanelEvent('changes:accept', entryIds) + } + updateEntries(docId) + }, + [currentDocumentId, getChangeTracker, updateEntries] + ) + ) + useSocketListener( + socket, + 'new-comment', + useCallback( + (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => { + setCommentThreads(prevState => { + const { submitting: _, ...thread } = getThread(threadId) + thread.messages = [...thread.messages] + thread.messages.push(formatComment(comment)) + return { ...prevState, [threadId]: thread } + }) + handleLayoutChange({ async: true }) + }, + [getThread] + ) + ) + useSocketListener( + socket, + 'new-comment-threads', + useCallback( + (threads: ReviewPanelCommentThreadsApi) => { + setCommentThreads(prevState => { + const newThreads = { ...prevState } + for (const threadIdString of Object.keys(threads)) { + const threadId = threadIdString as ThreadId + const { submitting: _, ...thread } = getThread(threadId) + // Replace already loaded messages with the server provided ones + thread.messages = threads[threadId].messages.map(formatComment) + newThreads[threadId] = thread + } + return newThreads + }) + handleLayoutChange({ async: true }) + }, + [getThread] + ) + ) + + const openSubView = useRef('cur_file') + useEffect(() => { + if (!reviewPanelOpen) { + // Always show current file when not open, but save current state + setSubView(prevState => { + openSubView.current = prevState + return 'cur_file' + }) + } else { + // Reset back to what we had when previously open + setSubView(openSubView.current) + } + handleLayoutChange({ async: true, animate: false }) + }, [reviewPanelOpen]) + + const canRefreshRanges = useRef(false) + const prevSubView = useRef(subView) + const initializedPrevSubView = useRef(false) + useEffect(() => { + // Prevent setting a computed value for `prevSubView` on mount + if (!initializedPrevSubView.current) { + initializedPrevSubView.current = true + return + } + prevSubView.current = subView === 'cur_file' ? 'overview' : 'cur_file' + // Allow refreshing ranges once for each `subView` change + canRefreshRanges.current = true + }, [subView]) + + useEffect(() => { + if (subView === 'overview' && canRefreshRanges.current) { + canRefreshRanges.current = false + + setIsOverviewLoading(true) + refreshRanges().finally(() => { + setIsOverviewLoading(false) + }) + } + }, [subView, refreshRanges]) + + useEffect(() => { + if (subView === 'cur_file' && prevSubView.current === 'overview') { + dispatchReviewPanelEvent('overview-closed', subView) + } + }, [subView]) + + useEffect(() => { + if (Object.keys(users).length) { + handleLayoutChange({ async: true }) + } + }, [users]) + + const values = useMemo( + () => ({ + collapsed, + commentThreads, + entries, + isAddingComment, + loadingThreads, + nVisibleSelectedChanges, + permissions, + users, + resolvedComments, + shouldCollapse, + navHeight, + toolbarHeight, + subView, + wantTrackChanges, + isOverviewLoading, + openDocId: currentDocumentId, + lineHeight, + trackChangesState, + trackChangesOnForEveryone, + trackChangesOnForGuests, + trackChangesForGuestsAvailable, + formattedProjectMembers, + layoutSuspended, + unsavedComment, + layoutToLeft, + }), + [ + collapsed, + commentThreads, + entries, + isAddingComment, + loadingThreads, + nVisibleSelectedChanges, + permissions, + users, + resolvedComments, + shouldCollapse, + navHeight, + toolbarHeight, + subView, + wantTrackChanges, + isOverviewLoading, + currentDocumentId, + lineHeight, + trackChangesState, + trackChangesOnForEveryone, + trackChangesOnForGuests, + trackChangesForGuestsAvailable, + formattedProjectMembers, + layoutSuspended, + unsavedComment, + layoutToLeft, + ] + ) + + const updaterFns = useMemo( + () => ({ + handleSetSubview, + handleLayoutChange, + gotoEntry, + resolveComment, + submitReply, + acceptChanges, + rejectChanges, + toggleReviewPanel, + bulkAcceptActions, + bulkRejectActions, + saveEdit, + submitNewComment, + deleteComment, + unresolveComment, + refreshResolvedCommentsDropdown: refreshRanges, + deleteThread, + toggleTrackChangesForEveryone, + toggleTrackChangesForUser, + toggleTrackChangesForGuests, + setCollapsed, + setShouldCollapse, + setIsAddingComment, + setNavHeight, + setToolbarHeight, + setLayoutSuspended, + setUnsavedComment, + }), + [ + handleSetSubview, + gotoEntry, + resolveComment, + submitReply, + acceptChanges, + rejectChanges, + toggleReviewPanel, + bulkAcceptActions, + bulkRejectActions, + saveEdit, + submitNewComment, + deleteComment, + unresolveComment, + refreshRanges, + deleteThread, + toggleTrackChangesForEveryone, + toggleTrackChangesForUser, + toggleTrackChangesForGuests, + setCollapsed, + setShouldCollapse, + setIsAddingComment, + setNavHeight, + setToolbarHeight, + setLayoutSuspended, + setUnsavedComment, + ] + ) + + return { values, updaterFns } +} + +export default useReviewPanelState 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 0000000000..12cbb57da4 --- /dev/null +++ b/services/web/modules/track-changes/app/src/TrackChangesController.js @@ -0,0 +1,194 @@ +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') + +function _transformId(doc) { + if (doc._id) { + doc.id = doc._id; + delete doc._id; + } + return doc; +} + +const TrackChangesController = { + async trackChanges(req, res, next) { + try { + 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 + await Project.updateOne({_id: project_id}, {track_changes: state}).exec() //do not wait? + EditorRealTimeController.emitToRoom(project_id, 'toggle-track-changes', state) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async acceptChanges(req, res, next) { + try { + const { project_id, doc_id } = req.params + const change_ids = req.body.change_ids + EditorRealTimeController.emitToRoom(project_id, 'accept-changes', doc_id, change_ids) + await DocumentUpdaterHandler.promises.acceptChanges(project_id, doc_id, change_ids) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async getAllRanges(req, res, next) { + try { + const { project_id } = req.params + // Flushing the project to mongo is not ideal. Is it possible to fetch the ranges from redis? + await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id) + const ranges = await DocstoreManager.promises.getAllRanges(project_id) + res.json(ranges.map(_transformId)) + } catch (err) { + next(err) + } + }, + async getChangesUsers(req, res, next) { + try { + const { project_id } = req.params + const memberIds = await CollaboratorsGetter.promises.getMemberIds(project_id) + // FIXME: Fails to display names in changes made by former project collaborators. + // See the alternative below. However, it requires flushing the project to mongo, which is not ideal. + 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 + res.json(users.map(_transformId)) + } catch (err) { + next(err) + } + }, +/* + async getChangesUsers(req, res, next) { + try { + const { project_id } = req.params + await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id) + const memberIds = new Set() + const ranges = await DocstoreManager.promises.getAllRanges(project_id) + ranges.forEach(range => { + ;[...range.ranges?.changes || [], ...range.ranges?.comments || []].forEach(item => { + memberIds.add(item.metadata?.user_id) + }) + }) + const limit = pLimit(3) + const users = await Promise.all( + [...memberIds].map(memberId => + limit(async () => { + if( memberId !== "anonymous-user") { + return await UserInfoManager.promises.getPersonalInfo(memberId) + } else { + return {_id: null} + } + }) + ) + ) + res.json(users.map(_transformId)) + } catch (err) { + next(err) + } + }, +*/ + async getThreads(req, res, next) { + try { + const { project_id } = req.params + const messages = await ChatApiHandler.promises.getThreads(project_id) + await ChatManager.promises.injectUserInfoIntoThreads(messages) + res.json(messages) + } catch (err) { + next(err) + } + }, + async sendComment(req, res, next) { + try { + const { project_id, thread_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + const message = await ChatApiHandler.promises.sendComment(project_id, thread_id, user_id, content) + message.user = await UserInfoManager.promises.getPersonalInfo(user_id) + EditorRealTimeController.emitToRoom(project_id, 'new-comment', thread_id, message) + res.sendStatus(204) + } catch (err) { + next(err); + } + }, + async editMessage(req, res, next) { + try { + const { project_id, thread_id, message_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.editMessage(project_id, thread_id, message_id, user_id, content) + EditorRealTimeController.emitToRoom(project_id, 'edit-message', thread_id, message_id, content) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async deleteMessage(req, res, next) { + try { + const { project_id, thread_id, message_id } = req.params + await ChatApiHandler.promises.deleteMessage(project_id, thread_id, message_id) + EditorRealTimeController.emitToRoom(project_id, 'delete-message', thread_id, message_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async resolveThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + const user = await UserInfoManager.promises.getPersonalInfo(user_id) + await ChatApiHandler.promises.resolveThread(project_id, thread_id, user_id) + EditorRealTimeController.emitToRoom(project_id, 'resolve-thread', thread_id, user) + await DocumentUpdaterHandler.promises.resolveThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204); + } catch (err) { + next(err); + } + }, + async reopenThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.reopenThread(project_id, thread_id) + EditorRealTimeController.emitToRoom(project_id, 'reopen-thread', thread_id) + await DocumentUpdaterHandler.promises.reopenThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async deleteThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.deleteThread(project_id, thread_id) + EditorRealTimeController.emitToRoom(project_id, 'delete-thread', thread_id) + await DocumentUpdaterHandler.promises.deleteThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, +} +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 0000000000..3791e251a1 --- /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 0000000000..aa9e6a73da --- /dev/null +++ b/services/web/modules/track-changes/index.js @@ -0,0 +1,2 @@ +const TrackChangesRouter = require('./app/src/TrackChangesRouter') +module.exports = { router : TrackChangesRouter }