From 1943c2d535eaa141d416f106e04a18ea0734dc4f Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 3 Dec 2024 01:07:49 +0100 Subject: [PATCH 1/2] Enable track changes and comments feature --- .../Features/Project/ProjectEditorHandler.js | 2 +- services/web/config/settings.defaults.js | 1 + .../hooks/use-review-panel-state.ts | 1659 +++++++++++++++++ .../hooks/use-codemirror-scope.ts | 2 +- .../app/src/TrackChangesController.js | 194 ++ .../app/src/TrackChangesRouter.js | 72 + services/web/modules/track-changes/index.js | 2 + 7 files changed, 1930 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts create mode 100644 services/web/modules/track-changes/app/src/TrackChangesController.js create mode 100644 services/web/modules/track-changes/app/src/TrackChangesRouter.js create mode 100644 services/web/modules/track-changes/index.js diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 05e5beba09..cd37c0f6d0 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -4,7 +4,7 @@ const Path = require('path') const Features = require('../../infrastructure/Features') module.exports = ProjectEditorHandler = { - trackChangesAvailable: false, + trackChangesAvailable: true, buildProjectModelView(project, members, invites) { let owner, ownerFeatures diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a7ff970ef0..72db6cef36 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1005,6 +1005,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/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index a4e2862e1f..bc7a99050d 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 @@ -185,7 +185,7 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { if (trackChanges) { - currentDocument.track_changes_as = userId || 'anonymous' + currentDocument.track_changes_as = userId || 'anonymous-user' } else { currentDocument.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 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 } From 271cd2512c6bdee94bfdf6be90f226999faa0dbf Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 8 Apr 2025 16:49:58 +0200 Subject: [PATCH 2/2] Track changes / comments: update backend to support frontend changes --- .../hooks/use-review-panel-state.ts | 1659 ----------------- .../app/src/TrackChangesController.js | 61 +- 2 files changed, 22 insertions(+), 1698 deletions(-) delete mode 100644 services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts 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 deleted file mode 100644 index c712384163..0000000000 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ /dev/null @@ -1,1659 +0,0 @@ -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 index 12cbb57da4..45f8a03e0f 100644 --- a/services/web/modules/track-changes/app/src/TrackChangesController.js +++ b/services/web/modules/track-changes/app/src/TrackChangesController.js @@ -3,6 +3,7 @@ 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 UserInfoController = require('../../../../app/src/Features/User/UserInfoController') const DocstoreManager = require('../../../../app/src/Features/Docstore/DocstoreManager') const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler') const CollaboratorsGetter = require('../../../../app/src/Features/Collaborators/CollaboratorsGetter') @@ -11,10 +12,10 @@ const pLimit = require('p-limit') function _transformId(doc) { if (doc._id) { - doc.id = doc._id; - delete doc._id; + doc.id = doc._id + delete doc._id } - return doc; + return doc } const TrackChangesController = { @@ -44,7 +45,6 @@ const TrackChangesController = { 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)) @@ -53,31 +53,12 @@ const TrackChangesController = { } }, async getChangesUsers(req, res, next) { +// This route was previously used by the frontend to retrieve names of users who made changes or comments. +// review-panel-new no longer needs this for comments, but still relies on it for changes - +// although the frontend knows the names of the current owner and members, it depends on the data +// provided here to assign names to authors who have left the project but still have unaccepted changes. 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 => { @@ -89,20 +70,16 @@ const TrackChangesController = { const users = await Promise.all( [...memberIds].map(memberId => limit(async () => { - if( memberId !== "anonymous-user") { - return await UserInfoManager.promises.getPersonalInfo(memberId) - } else { - return {_id: null} - } + const user = await UserInfoManager.promises.getPersonalInfo(memberId) + return UserInfoController.formatPersonalInfo(user) }) ) ) - res.json(users.map(_transformId)) + res.json(users) } catch (err) { next(err) } }, -*/ async getThreads(req, res, next) { try { const { project_id } = req.params @@ -120,11 +97,12 @@ const TrackChangesController = { 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) + const user = await UserInfoManager.promises.getPersonalInfo(user_id) + message.user = UserInfoController.formatPersonalInfo(user) EditorRealTimeController.emitToRoom(project_id, 'new-comment', thread_id, message) res.sendStatus(204) } catch (err) { - next(err); + next(err) } }, async editMessage(req, res, next) { @@ -157,11 +135,16 @@ const TrackChangesController = { 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) + EditorRealTimeController.emitToRoom( + project_id, + 'resolve-thread', + thread_id, + UserInfoController.formatPersonalInfo(user) + ) await DocumentUpdaterHandler.promises.resolveThread(project_id, doc_id, thread_id, user_id) - res.sendStatus(204); + res.sendStatus(204) } catch (err) { - next(err); + next(err) } }, async reopenThread(req, res, next) {