From 1f39b6d72a78eb38bef62d85e242bf196d6ae268 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:36:19 +0200 Subject: [PATCH] Merge pull request #15756 from overleaf/ii-ide-page-prototype-review-panel-entries [web] init review panel entries for React IDE page GitOrigin-RevId: f6e6311e20f1673b1d97a3f5dfcab54e16da42e1 --- .../context/editor-manager-context.tsx | 9 +- .../hooks/use-review-panel-state.ts | 436 ++++++++++++++++-- .../review-panel-context-adapter.ts | 6 +- services/web/types/review-panel/api.ts | 14 + .../web/types/review-panel/comment-thread.ts | 2 +- .../web/types/review-panel/review-panel.ts | 2 +- 6 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 services/web/types/review-panel/api.ts diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index efb4787121..3c9921a6cc 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -29,6 +29,7 @@ import { useTranslation } from 'react-i18next' import customLocalStorage from '@/infrastructure/local-storage' import useEventListener from '@/shared/hooks/use-event-listener' import { EditorType } from '@/features/ide-react/editor/types/editor-type' +import { DocId } from '../../../../../types/project-settings' interface GotoOffsetOptions { gotoOffset: number @@ -45,9 +46,9 @@ type EditorManager = { getEditorType: () => EditorType | null showSymbolPalette: boolean currentDocument: Document - currentDocumentId: string | null + currentDocumentId: DocId | null getCurrentDocValue: () => string | null - getCurrentDocId: () => string | null + getCurrentDocId: () => DocId | null startIgnoringExternalUpdates: () => void stopIgnoringExternalUpdates: () => void openDocId: (docId: string, options?: OpenDocOptions) => void @@ -96,7 +97,7 @@ export const EditorManagerProvider: FC = ({ children }) => { const [showVisual] = useScopeValue('editor.showVisual') const [currentDocument, setCurrentDocument] = useScopeValue('editor.sharejs_doc') - const [openDocId, setOpenDocId] = useScopeValue( + const [openDocId, setOpenDocId] = useScopeValue( 'editor.open_doc_id' ) const [, setOpenDocName] = useScopeValue( @@ -418,7 +419,7 @@ export const EditorManagerProvider: FC = ({ children }) => { } // We're now either opening a new document or reloading a broken one. - setOpenDocId(doc._id) + setOpenDocId(doc._id as DocId) setOpenDocName(doc.name) setOpening(true) diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts index 8f3dd79c17..151ec924b6 100644 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts @@ -1,6 +1,9 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { isEqual, cloneDeep } from 'lodash' import useScopeValue from '../../../../../shared/hooks/use-scope-value' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' +import useAsync from '@/shared/hooks/use-async' +import useAbortController from '@/shared/hooks/use-abort-controller' import { sendMB } from '../../../../../infrastructure/event-tracking' import { dispatchReviewPanelLayout as handleLayoutChange } from '@/features/source-editor/extensions/changes/change-manager' import { useProjectContext } from '@/shared/context/project-context' @@ -9,19 +12,49 @@ 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 { debugConsole } from '@/utils/debugging' -import { postJSON } from '@/infrastructure/fetch-json' -import { ReviewPanelStateReactIde } from '../types/review-panel-state' +import { useEditorContext } from '@/shared/context/editor-context' +import { getJSON, postJSON } from '@/infrastructure/fetch-json' import ColorManager from '@/ide/colors/ColorManager' +// @ts-ignore +import RangesTracker from '@overleaf/ranges-tracker' +import { ReviewPanelStateReactIde } from '../types/review-panel-state' import * as ReviewPanel from '../types/review-panel-state' import { + 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 } from '../../../../../../../types/utils' +import { + DeepReadonly, + MergeAndOverride, +} from '../../../../../../../types/utils' +import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread' +import { DocId } from '../../../../../../../types/project-settings' +import { + ReviewPanelAggregateChangeEntry, + ReviewPanelChangeEntry, + ReviewPanelCommentEntry, + ReviewPanelEntry, +} from '../../../../../../../types/review-panel/entry' +import { + ReviewPanelCommentThreadMessageApi, + ReviewPanelCommentThreadsApi, +} from '../../../../../../../types/review-panel/api' +import { Document } from '@/features/ide-react/editor/document' -function formatUser(user: any): any { +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) || @@ -62,6 +95,15 @@ function formatUser(user: any): any { } } +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(): ReviewPanelStateReactIde { const { reviewPanelOpen, setReviewPanelOpen } = useLayoutContext() const { projectId } = useIdeReactContext() @@ -71,10 +113,14 @@ function useReviewPanelState(): ReviewPanelStateReactIde { const { features: { trackChangesVisible, trackChanges }, } = project + const { isRestrictedTokenMember } = useEditorContext() - const [subView, setSubView] = useScopeValue>( - 'reviewPanel.subView' - ) + // TODO `currentDocument` and `currentDocumentId` should be get from `useEditorManagerContext()` but that makes tests fail + const [currentDocument] = useScopeValue('editor.sharejs_doc') + const [currentDocumentId] = useScopeValue('editor.open_doc_id') + + const [subView, setSubView] = + useState>('cur_file') const [loading] = useScopeValue>( 'reviewPanel.overview.loading' ) @@ -84,29 +130,25 @@ function useReviewPanelState(): ReviewPanelStateReactIde { const [collapsed, setCollapsed] = useScopeValue< ReviewPanel.Value<'collapsed'> >('reviewPanel.overview.docsCollapsedState') - const [commentThreads] = useScopeValue>( - 'reviewPanel.commentThreads', - true - ) - const [entries] = useScopeValue>( - 'reviewPanel.entries', - true - ) - const [loadingThreads] = - useScopeValue>('loadingThreads') + const [commentThreads, setCommentThreads] = useState< + ReviewPanel.Value<'commentThreads'> + >({}) + const [entries, setEntries] = useState>({}) const [permissions] = useScopeValue>('permissions') - const [users] = useScopeValue>('users', true) - const [resolvedComments] = useScopeValue< + const [users, setUsers] = useScopeValue>( + 'users', + true + ) + const [resolvedComments, setResolvedComments] = useState< ReviewPanel.Value<'resolvedComments'> - >('reviewPanel.resolvedComments', true) + >({}) const [wantTrackChanges, setWantTrackChanges] = useScopeValue< ReviewPanel.Value<'wantTrackChanges'> >('editor.wantTrackChanges') - const [openDocId] = - useScopeValue>('editor.open_doc_id') + const openDocId = currentDocumentId const [shouldCollapse, setShouldCollapse] = useState>(true) const [lineHeight] = useScopeValue( @@ -126,6 +168,325 @@ function useReviewPanelState(): ReviewPanelStateReactIde { const [trackChangesForGuestsAvailable, setTrackChangesForGuestsAvailable] = useState>(false) + const [resolvedThreadIds, setResolvedThreadIds] = useState< + Record + >({}) + + const { + isLoading: loadingThreads, + reset, + runAsync: runAsyncThreads, + } = useAsync() + const loadThreadsController = useAbortController() + const loadThreadsExecuted = useRef(false) + const ensureThreadsAreLoaded = useCallback(() => { + if (loadThreadsExecuted.current) { + // We get any updates in real time so only need to load them once. + return + } + loadThreadsExecuted.current = true + + return runAsyncThreads( + getJSON(`/project/${projectId}/threads`, { + signal: loadThreadsController.signal, + }) + ) + .then(threads => { + 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) + }, [loadThreadsController.signal, projectId, runAsyncThreads]) + + const rangesTrackers = useRef>({}) + 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) { + tempUsers[user.id] = formatUser(user) + } + + for (const user of usersResponse) { + if (user.id) { + tempUsers[user.id] = formatUser(user) + } + } + + setUsers(tempUsers) + }) + .catch(error => { + refreshingRangeUsers.current = false + debugConsole.error(error) + }) + }, + [projectId, setUsers, user] + ) + + const getChangeTracker = useCallback( + (docId: DocId) => { + if (!rangesTrackers.current[docId]) { + rangesTrackers.current[docId] = new RangesTracker() as RangesTracker + rangesTrackers.current[docId].resolvedThreadIds = { + ...resolvedThreadIds, + } + } + 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 updateEntries = useCallback( + async (docId: DocId) => { + const rangesTracker = getChangeTracker(docId) + let localResolvedThreadIds = resolvedThreadIds + + if (!isRestrictedTokenMember) { + if (rangesTracker.comments.length > 0) { + const threadsLoadResult = await ensureThreadsAreLoaded() + if (typeof threadsLoadResult === 'object') { + localResolvedThreadIds = threadsLoadResult.resolvedThreadIds + } + } else if (loadingThreads) { + // ensure that tracked changes are highlighted even if no comments are loaded + reset() + dispatchReviewPanelEvent('loaded_threads') + } + } + + 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)) { + 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)) { + 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, + } + const newEntryEntries = Object.entries(newEntry) as [ + [keyof typeof newEntry, typeof newEntry[keyof typeof newEntry]] + ] + for (const [key, value] of newEntryEntries) { + 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) + } + } + } + + for (const comment of rangesTracker.comments) { + deleteChanges.delete(comment.id) + + const newEntry: Partial = { + type: 'comment', + thread_id: comment.op.t, + entry_ids: [comment.id], + content: comment.op.c, + offset: comment.op.p, + } + const newEntryEntries = Object.entries(newEntry) as [ + [keyof typeof newEntry, typeof newEntry[keyof typeof newEntry]] + ] + + let newComment: any + if (localResolvedThreadIds[comment.op.t]) { + docResolvedComments[comment.id] ??= {} as ReviewPanelCommentEntry + newComment = docResolvedComments[comment.id] + delete docEntries[comment.id] + } else { + docEntries[comment.id] ??= {} as ReviewPanelEntry + newComment = docEntries[comment.id] + delete docResolvedComments[comment.id] + } + + for (const [key, value] of newEntryEntries) { + newComment[key] = value + } + } + + 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, + isRestrictedTokenMember, + refreshChangeUsers, + resolvedThreadIds, + users, + ensureThreadsAreLoaded, + loadingThreads, + reset, + ] + ) + + const regenerateTrackChangesId = useCallback( + (doc: typeof currentDocument) => { + 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 + rangesTrackers.current[currentDocument.doc_id as DocId] = + currentDocument.ranges + rangesTrackers.current[currentDocument.doc_id as DocId].resolvedThreadIds = + { ...resolvedThreadIds } + 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' => { if (!user) { return 'anonymous' @@ -387,6 +748,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde { const projectJoinedEffectExecuted = useRef(false) useEffect(() => { if (!projectJoinedEffectExecuted.current) { + projectJoinedEffectExecuted.current = true requestAnimationFrame(() => { if (trackChanges) { applyTrackChangesStateToClient(project.trackChangesState) @@ -395,7 +757,6 @@ function useReviewPanelState(): ReviewPanelStateReactIde { } setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel) }) - projectJoinedEffectExecuted.current = true } }, [ applyTrackChangesStateToClient, @@ -489,13 +850,10 @@ function useReviewPanelState(): ReviewPanelStateReactIde { 'bulkRejectActions' ) - const handleSetSubview = useCallback( - (subView: SubView) => { - setSubView(subView) - sendMB('rp-subview-change', { subView }) - }, - [setSubView] - ) + const handleSetSubview = useCallback((subView: SubView) => { + setSubView(subView) + sendMB('rp-subview-change', { subView }) + }, []) const submitReply = useCallback( (threadId: ThreadId, replyContent: string) => { @@ -523,11 +881,27 @@ function useReviewPanelState(): ReviewPanelStateReactIde { } } + const editorTrackChangesChanged = async () => { + const entries = await updateEntries(currentDocumentId) + dispatchReviewPanelEvent('recalculate-screen-positions', { + entries, + updateType: 'trackedChangesChange', + }) + // Ensure that watchers, such as the React-based review panel component, + // are informed of the changes to entries + handleLayoutChange() + } + const handleEditorEvents = (e: Event) => { const event = e as CustomEvent const { type } = event.detail switch (type) { + case 'track-changes:changed': { + editorTrackChangesChanged() + break + } + case 'toggle-track-changes': { toggleTrackChangesFromKbdShortcut() break @@ -541,10 +915,12 @@ function useReviewPanelState(): ReviewPanelStateReactIde { window.removeEventListener('editor:event', handleEditorEvents) } }, [ + currentDocumentId, toggleTrackChangesForUser, trackChanges, trackChangesState, trackChangesVisible, + updateEntries, user.id, ]) diff --git a/services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts b/services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts index 72990f3a02..c3f2a4a89c 100644 --- a/services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts +++ b/services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts @@ -2,12 +2,8 @@ import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/rea export default function populateReviewPanelScope(store: ReactScopeValueStore) { store.set('reviewPanel.overview.docsCollapsedState', {}) - store.set('reviewPanel.subView', 'cur_file') store.set('reviewPanel.overview.loading', false) store.set('reviewPanel.nVisibleSelectedChanges', 0) - store.set('reviewPanel.commentThreads', {}) - store.set('reviewPanel.entries', {}) - store.set('loadingThreads', true) store.set('permissions', { read: false, write: false, @@ -15,7 +11,7 @@ export default function populateReviewPanelScope(store: ReactScopeValueStore) { comment: false, }) store.set('users', {}) - store.set('reviewPanel.resolvedComments', {}) + store.set('reviewPanel.layoutToLeft', false) store.set('reviewPanel.rendererData.lineHeight', 0) store.set('resolveComment', () => {}) store.set('submitNewComment', async () => {}) diff --git a/services/web/types/review-panel/api.ts b/services/web/types/review-panel/api.ts new file mode 100644 index 0000000000..45c185a63f --- /dev/null +++ b/services/web/types/review-panel/api.ts @@ -0,0 +1,14 @@ +import { ReviewPanelCommentThreadMessage, ThreadId } from './review-panel' +import { MergeAndOverride } from '../utils' + +export type ReviewPanelCommentThreadMessageApi = MergeAndOverride< + ReviewPanelCommentThreadMessage, + { timestamp: number } +> + +export type ReviewPanelCommentThreadsApi = Record< + ThreadId, + { + messages: ReviewPanelCommentThreadMessageApi[] + } +> diff --git a/services/web/types/review-panel/comment-thread.ts b/services/web/types/review-panel/comment-thread.ts index 1df267a476..da59bdee22 100644 --- a/services/web/types/review-panel/comment-thread.ts +++ b/services/web/types/review-panel/comment-thread.ts @@ -5,7 +5,7 @@ import { import { UserId } from '../user' import { DateString } from '../helpers/date' -interface ReviewPanelCommentThreadBase { +export interface ReviewPanelCommentThreadBase { messages: Array submitting?: boolean // angular specific (to be made into a local state) } diff --git a/services/web/types/review-panel/review-panel.ts b/services/web/types/review-panel/review-panel.ts index b6024fbfd3..d76e099a85 100644 --- a/services/web/types/review-panel/review-panel.ts +++ b/services/web/types/review-panel/review-panel.ts @@ -46,7 +46,7 @@ export type CommentId = Brand export interface ReviewPanelCommentThreadMessage { content: string id: CommentId - timestamp: number + timestamp: Date user: ReviewPanelUser user_id: UserId }