diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 0c6a28d57d..ee3be6d2ee 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -345,7 +345,6 @@ const _ProjectController = { 'pdf-caching-prefetching', 'revert-file', 'revert-project', - 'review-panel-redesign', !anonymous && 'ro-mirror-on-client', 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', diff --git a/services/web/cypress/support/component.ts b/services/web/cypress/support/component.ts index 00e359b4ff..9ded533da3 100644 --- a/services/web/cypress/support/component.ts +++ b/services/web/cypress/support/component.ts @@ -1,6 +1,6 @@ import 'cypress-plugin-tab' import { resetMeta } from './ct/window' // needs to be before i18n -import '@/i18n' +import localesPromise from '@/i18n' import './shared/commands' import './shared/exceptions' import './ct/commands' @@ -8,5 +8,5 @@ import './ct/codemirror' import '../../test/frontend/helpers/bootstrap-5' beforeEach(function () { - resetMeta() + cy.wrap(localesPromise).then(resetMeta) }) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index e9da93da86..5a4e7d818d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -32,8 +32,6 @@ "about_to_leave_projects": "", "about_to_trash_projects": "", "about_writefull": "", - "accept": "", - "accept_all": "", "accept_and_continue": "", "accept_change": "", "accept_change_error_description": "", @@ -147,7 +145,6 @@ "are_you_affiliated_with_an_institution": "", "are_you_getting_an_undefined_control_sequence_error": "", "are_you_still_at": "", - "are_you_sure": "", "are_you_sure_you_want_to_cancel_add_on": "", "as_email": "", "ask_proj_owner_to_unlink_from_current_github": "", @@ -185,8 +182,6 @@ "blocked_filename": "", "blog": "", "browser": "", - "bulk_accept_confirm": "", - "bulk_reject_confirm": "", "by_subscribing_you_agree_to_our_terms_of_service": "", "can_add_tracked_changes_and_comments": "", "can_edit": "", @@ -273,7 +268,6 @@ "column_width_is_custom_click_to_resize": "", "column_width_is_x_click_to_resize": "", "comment": "", - "comment_submit_error": "", "commit": "", "common": "", "common_causes_of_compile_timeouts_include": "", @@ -522,7 +516,6 @@ "error_opening_document_detail": "", "error_performing_request": "", "error_processing_file": "", - "error_submitting_comment": "", "example_project": "", "existing_plan_active_until_term_end": "", "expand": "", @@ -724,7 +717,6 @@ "history_view_a11y_description": "", "history_view_all": "", "history_view_labels": "", - "hit_enter_to_reply": "", "home": "", "hotkey_add_a_comment": "", "hotkey_autocomplete_menu": "", @@ -980,7 +972,6 @@ "managed_users_terms": "", "managers_management": "", "managing_your_subscription": "", - "mark_as_resolved": "", "marked_as_resolved": "", "math_display": "", "math_inline": "", @@ -1051,7 +1042,6 @@ "no_actions": "", "no_borders": "", "no_caption": "", - "no_comments": "", "no_comments_or_suggestions": "", "no_existing_password": "", "no_folder": "", @@ -1071,7 +1061,6 @@ "no_preview_available": "", "no_projects": "", "no_resolved_comments": "", - "no_resolved_threads": "", "no_search_results": "", "no_selection_select_file": "", "no_symbols_found": "", @@ -1284,7 +1273,6 @@ "push_sharelatex_changes_to_github": "", "push_to_github_pull_to_overleaf": "", "quoted_text": "", - "quoted_text_in": "", "raw_logs": "", "raw_logs_description": "", "react_history_tutorial_content": "", @@ -1331,8 +1319,6 @@ "refresh_page_after_starting_free_trial": "", "refreshing": "", "regards": "", - "reject": "", - "reject_all": "", "reject_change": "", "reject_selected_changes": "", "relink_your_account": "", @@ -1377,7 +1363,6 @@ "resending_confirmation_code": "", "resending_confirmation_email": "", "resize": "", - "resolve": "", "resolve_comment": "", "resolve_comment_error_message": "", "resolve_comment_error_title": "", @@ -1514,7 +1499,6 @@ "shared_with_you": "", "sharelatex_beta_program": "", "shortcut_to_open_advanced_reference_search": "", - "show_all": "", "show_all_projects": "", "show_document_preamble": "", "show_file_tree": "", @@ -1672,9 +1656,6 @@ "take_survey": "", "tc_everyone": "", "tc_guests": "", - "tc_switch_everyone_tip": "", - "tc_switch_guests_tip": "", - "tc_switch_user_tip": "", "tell_the_project_owner_and_ask_them_to_upgrade": "", "template": "", "template_description": "", diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index 4fe590d9df..313989903a 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx @@ -9,7 +9,6 @@ import React, { } from 'react' import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' import populateLayoutScope from '@/features/ide-react/scope-adapters/layout-context-adapter' -import populateReviewPanelScope from '@/features/ide-react/scope-adapters/review-panel-context-adapter' import { IdeProvider } from '@/shared/context/ide-context' import { createIdeEventEmitter, @@ -73,10 +72,8 @@ export function createReactScopeValueStore(projectId: string) { populateLayoutScope(scopeStore) populateProjectScope(scopeStore) populatePdfScope(scopeStore) - populateReviewPanelScope(scopeStore) scopeStore.allowNonExistentPath('hasLintingError') - scopeStore.allowNonExistentPath('loadingThreads') return scopeStore } 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 e9d467a216..0000000000 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ /dev/null @@ -1,1654 +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 { - 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?.id) { - 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]) { - 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' => { - if (!user) { - return 'anonymous' - } - 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') { - 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/ide-react/context/review-panel/hooks/useLayoutToLeft.tsx b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/useLayoutToLeft.tsx deleted file mode 100644 index b971a268a0..0000000000 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/useLayoutToLeft.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useEffect } from 'react' - -function useLayoutToLeft(querySelector: string) { - const [layoutToLeft, setLayoutToLeft] = useState(false) - - useEffect(() => { - if (!('ResizeObserver' in window)) return - - const target = document.querySelector(querySelector) - - if (!target) return - - const handleResize = () => { - const docWidth = document.documentElement.clientWidth - const { right: rightEdge } = target.getBoundingClientRect() - setLayoutToLeft(docWidth - rightEdge < 225) - } - - handleResize() - - const observer = new ResizeObserver(handleResize) - observer.observe(target) - - return () => { - observer.disconnect() - } - }, [querySelector]) - - return layoutToLeft -} - -export default useLayoutToLeft 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 deleted file mode 100644 index 2bf212f2c6..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' - -export default function populateReviewPanelScope(store: ReactScopeValueStore) { - store.set('loadingThreads', true) - store.set('users', {}) - store.set('usingNewReviewPanel', isSplitTestEnabled('review-panel-redesign')) -} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx index e90c0578e8..6dafe4dcf0 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx @@ -20,7 +20,6 @@ const ReviewPanelHeader: FC = () => { setReviewPanelOpen(false)} - splitTestName="review-panel-redesign" > {isReviewerRoleEnabled && } diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-new.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-new.tsx new file mode 100644 index 0000000000..f4a9e37048 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-new.tsx @@ -0,0 +1,12 @@ +import React, { FC, lazy, Suspense } from 'react' +import LoadingSpinner from '@/shared/components/loading-spinner' + +const ReviewPanelContainer = lazy(() => import('./review-panel-container')) + +export const ReviewPanelNew: FC = () => { + return ( + }> + + + ) +} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx index 230a945335..60657e82d3 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx @@ -3,7 +3,7 @@ import { Trans } from 'react-i18next' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import MaterialIcon from '@/shared/components/material-icon' import { useProjectContext } from '@/shared/context/project-context' -import UpgradeTrackChangesModal from '@/features/source-editor/components/review-panel/upgrade-track-changes-modal' +import UpgradeTrackChangesModal from '@/features/review-panel-new/components/upgrade-track-changes-modal' import { send, sendMB } from '@/infrastructure/event-tracking' const sendAnalytics = () => { diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx index 57abc3e3b3..85f5cb4c26 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx @@ -1,5 +1,5 @@ import { FC } from 'react' -import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle' +import TrackChangesToggle from '@/features/review-panel-new/components/track-changes-toggle' import { useProjectContext } from '@/shared/context/project-context' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' import { useTranslation } from 'react-i18next' diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-toggle.tsx b/services/web/frontend/js/features/review-panel-new/components/track-changes-toggle.tsx similarity index 100% rename from services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-toggle.tsx rename to services/web/frontend/js/features/review-panel-new/components/track-changes-toggle.tsx diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx similarity index 92% rename from services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx rename to services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx index 9de6dd1cd2..6f89c336a3 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next' -import { useProjectContext } from '../../../../shared/context/project-context' -import { useUserContext } from '../../../../shared/context/user-context' -import teaserVideo from '../../images/teaser-track-changes.mp4' -import teaserImage from '../../images/teaser-track-changes.gif' +import { useProjectContext } from '@/shared/context/project-context' +import { useUserContext } from '@/shared/context/user-context' +import teaserVideo from '../images/teaser-track-changes.mp4' +import teaserImage from '../images/teaser-track-changes.gif' import { startFreeTrial, upgradePlan } from '@/main/account-upgrade' import { memo } from 'react' import { useFeatureFlag } from '@/shared/context/split-test-context' diff --git a/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx b/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx index 6da12629fa..e875736f52 100644 --- a/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx @@ -10,6 +10,8 @@ import { getJSON } from '@/infrastructure/fetch-json' import { useProjectContext } from '@/shared/context/project-context' import { UserId } from '../../../../../types/user' import { useEditorContext } from '@/shared/context/editor-context' +import { debugConsole } from '@/utils/debugging' +import { captureException } from '@/infrastructure/error-reporter' export type ChangesUser = { id: UserId @@ -35,9 +37,12 @@ export const ChangesUsersProvider: FC = ({ children }) => { return } - getJSON(`/project/${projectId}/changes/users`).then(data => - setChangesUsers(new Map(data.map(item => [item.id, item]))) - ) + getJSON(`/project/${projectId}/changes/users`) + .then(data => setChangesUsers(new Map(data.map(item => [item.id, item])))) + .catch(error => { + debugConsole.error(error) + captureException(error) + }) }, [projectId, isRestrictedTokenMember]) // add the project owner and members to the changes users data diff --git a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx index 4aef7c4cc0..34cb175129 100644 --- a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx @@ -3,14 +3,9 @@ import { RangesProvider } from './ranges-context' import { ChangesUsersProvider } from './changes-users-context' import { TrackChangesStateProvider } from './track-changes-state-context' import { ThreadsProvider } from './threads-context' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { ReviewPanelViewProvider } from './review-panel-view-context' export const ReviewPanelProviders: FC = ({ children }) => { - if (!isSplitTestEnabled('review-panel-redesign')) { - return <>{children} - } - return ( diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index 01527e0517..fce47e9054 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -10,18 +10,20 @@ import { import { useProjectContext } from '@/shared/context/project-context' import { CommentId, + ReviewPanelCommentThreadMessage, ThreadId, } from '../../../../../types/review-panel/review-panel' import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' -import { ReviewPanelCommentThreadMessageApi } from '../../../../../types/review-panel/api' import { UserId } from '../../../../../types/user' import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' import RangesTracker from '@overleaf/ranges-tracker' import { CommentOperation } from '../../../../../types/change' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import { useEditorContext } from '@/shared/context/editor-context' +import { debugConsole } from '@/utils/debugging' +import { captureException } from '@/infrastructure/error-reporter' export type Threads = Record @@ -64,12 +66,15 @@ export const ThreadsProvider: FC = ({ children }) => { getJSON(`/project/${projectId}/threads`, { signal: abortController.signal, - }).then(data => { - setData(data) }) - // .catch(error => { - // setError(error) - // }) + .then(data => { + setData(data) + }) + .catch(error => { + debugConsole.error(error) + captureException(error) + // setError(error) + }) }, [projectId, isRestrictedTokenMember]) const { socket } = useConnectionContext() @@ -78,7 +83,10 @@ export const ThreadsProvider: FC = ({ children }) => { socket, 'new-comment', useCallback( - (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => { + ( + threadId: ThreadId, + comment: ReviewPanelCommentThreadMessage & { timestamp: number } + ) => { setData(value => { if (value) { const { submitting, ...thread } = value[threadId] ?? { diff --git a/services/web/frontend/js/features/source-editor/images/teaser-track-changes.gif b/services/web/frontend/js/features/review-panel-new/images/teaser-track-changes.gif similarity index 100% rename from services/web/frontend/js/features/source-editor/images/teaser-track-changes.gif rename to services/web/frontend/js/features/review-panel-new/images/teaser-track-changes.gif diff --git a/services/web/frontend/js/features/source-editor/images/teaser-track-changes.mp4 b/services/web/frontend/js/features/review-panel-new/images/teaser-track-changes.mp4 similarity index 100% rename from services/web/frontend/js/features/source-editor/images/teaser-track-changes.mp4 rename to services/web/frontend/js/features/review-panel-new/images/teaser-track-changes.mp4 diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index 7d0093fa5b..2c6e08a536 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -10,9 +10,8 @@ import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip' import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import { FigureModal } from './figure-modal/figure-modal' import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers' -import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration' +import { ReviewPanelNew } from '@/features/review-panel-new/components/review-panel-new' import ReviewTooltipMenu from '@/features/review-panel-new/components/review-tooltip-menu' -import { useFeatureFlag } from '@/shared/context/split-test-context' import { CodeMirrorStateContext, CodeMirrorViewContext, @@ -40,7 +39,6 @@ function CodeMirrorEditor() { const isMounted = useIsMounted() - const newReviewPanel = useFeatureFlag('review-panel-redesign') const newEditor = useIsNewEditorEnabled() // create the view using the initial state and intercept transactions @@ -79,8 +77,8 @@ function CodeMirrorEditor() { - {newReviewPanel && } - + + {sourceEditorComponents.map( ({ import: { default: Component }, path }) => ( diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/add-comment-button.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/add-comment-button.tsx deleted file mode 100644 index 5dca2c7eae..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/add-comment-button.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function AddCommentButton(props: React.ComponentPropsWithoutRef<'button'>) { - return - ) -} - -const TrackChangesOn = memo(() => { - return ( - }} /> - ) -}) -TrackChangesOn.displayName = 'TrackChangesOn' - -export default ToggleWidget diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx deleted file mode 100644 index 248bcab17c..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { useEffect, useRef, useState } from 'react' -import EntryContainer from './entry-container' -import EntryCallout from './entry-callout' -import EntryActions from './entry-actions' -import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area' -import AddCommentButton from '../add-comment-button' -import { - useReviewPanelUpdaterFnsContext, - useReviewPanelValueContext, -} from '../../../context/review-panel/review-panel-context' -import classnames from 'classnames' -import MaterialIcon from '@/shared/components/material-icon' -import LoadingSpinner from '@/shared/components/loading-spinner' - -function AddCommentEntry() { - const { t } = useTranslation() - const { isAddingComment, unsavedComment } = useReviewPanelValueContext() - const { - setIsAddingComment, - submitNewComment, - handleLayoutChange, - setUnsavedComment, - } = useReviewPanelUpdaterFnsContext() - - const [content, setContent] = useState(unsavedComment) - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleStartNewComment = () => { - setIsAddingComment(true) - handleLayoutChange({ async: true }) - } - - const handleSubmitNewComment = () => { - setIsSubmitting(true) - try { - submitNewComment(content) - setIsSubmitting(false) - setIsAddingComment(false) - setContent('') - } catch (err) { - setIsSubmitting(false) - } - handleLayoutChange({ async: true }) - } - - const handleCancelNewComment = () => { - setIsAddingComment(false) - setContent('') - handleLayoutChange({ async: true }) - } - - useEffect(() => { - return () => { - setIsAddingComment(false) - } - }, [setIsAddingComment]) - - const unsavedCommentRef = useRef(unsavedComment) - - // Keep unsaved comment ref up to date for use when the component unmounts - useEffect(() => { - unsavedCommentRef.current = content - }, [content]) - - // Store the unsaved comment in the context on unmount - useEffect(() => { - return () => { - setUnsavedComment(unsavedCommentRef.current) - } - }, [setUnsavedComment]) - - const handleCommentKeyPress = ( - e: React.KeyboardEvent - ) => { - const target = e.target as HTMLTextAreaElement - - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - if (content.length) { - handleSubmitNewComment() - } - } - - if (['PageDown', 'PageUp'].includes(e.key)) { - if (target.closest('textarea')) { - e.preventDefault() - } - } - } - - const handleCommentAutoFocus = (textarea: HTMLTextAreaElement) => { - // Sometimes the comment textarea is scrolled out of view once focussed, - // so this checks for that and scrolls it into view if necessary. It - // seems we sometimes need to allow time for the dust to settle after - // focussing the textarea before scrolling. - window.setTimeout(() => { - const observer = new IntersectionObserver(([entry]) => { - if (entry.intersectionRatio < 1) { - textarea.scrollIntoView({ block: 'center' }) - } - observer.disconnect() - }) - observer.observe(textarea) - }, 500) - } - - return ( - - -
- {isAddingComment ? ( - <> -
- {isSubmitting ? ( - - ) : ( - setContent(e.target.value)} - onKeyPress={handleCommentKeyPress} - onResize={handleLayoutChange} - onAutoFocus={handleCommentAutoFocus} - placeholder={t('add_your_comment_here')} - value={content} - autoFocus // eslint-disable-line jsx-a11y/no-autofocus - /> - )} -
- - - -   - {t('cancel')} - - - -   - {t('comment')} - - - - ) : ( - - -   - {t('add_comment')} - - )} -
-
- ) -} - -export default AddCommentEntry diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-change-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-change-entry.tsx deleted file mode 100644 index 635aaf1f52..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-change-entry.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { memo, useState } from 'react' -import EntryContainer from './entry-container' -import EntryCallout from './entry-callout' -import EntryActions from './entry-actions' -import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context' -import { formatTime } from '../../../../utils/format-date' -import classnames from 'classnames' -import comparePropsWithShallowArrayCompare from '../utils/compare-props-with-shallow-array-compare' -import { BaseChangeEntryProps } from '../types/base-change-entry-props' -import useIndicatorHover from '../hooks/use-indicator-hover' -import EntryIndicator from './entry-indicator' -import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click' -import MaterialIcon from '@/shared/components/material-icon' - -interface AggregateChangeEntryProps extends BaseChangeEntryProps { - replacedContent: string -} - -function AggregateChangeEntry({ - docId, - entryId, - permissions, - user, - content, - replacedContent, - offset, - focused, - entryIds, - timestamp, - contentLimit = 17, -}: AggregateChangeEntryProps) { - const { t } = useTranslation() - const { acceptChanges, rejectChanges, handleLayoutChange } = - useReviewPanelUpdaterFnsContext() - const [isDeletionCollapsed, setIsDeletionCollapsed] = useState(true) - const [isInsertionCollapsed, setIsInsertionCollapsed] = useState(true) - const { - hoverCoords, - indicatorRef, - endHover, - handleIndicatorMouseEnter, - handleIndicatorClick, - } = useIndicatorHover() - - const deletionNeedsCollapsing = replacedContent.length > contentLimit - const insertionNeedsCollapsing = content.length > contentLimit - - const deletionContent = isDeletionCollapsed - ? replacedContent.substring(0, contentLimit) - : replacedContent - - const insertionContent = isInsertionCollapsed - ? content.substring(0, contentLimit) - : content - - const handleEntryClick = useEntryClick(docId, offset, endHover) - - const handleDeletionToggleCollapse = () => { - setIsDeletionCollapsed(value => !value) - handleLayoutChange() - } - - const handleInsertionToggleCollapse = () => { - setIsInsertionCollapsed(value => !value) - handleLayoutChange() - } - - return ( - - - - - -
-
-
- -
-
-
- {t('aggregate_changed')}  - {deletionContent} - {deletionNeedsCollapsing && ( - - )}{' '} - {t('aggregate_to')}  - {insertionContent} - {insertionNeedsCollapsing && ( - - )} -
-
- - {formatTime(timestamp, 'MMM D, Y h:mm A')} - - {user && ( - -  •  - - {user.name ?? t('anonymous')} - - - )} -
-
-
- {permissions.write && ( - - rejectChanges(entryIds)}> - -  {t('reject')} - - acceptChanges(entryIds)}> - -  {t('accept')} - - - )} -
-
- ) -} - -export default memo( - AggregateChangeEntry, - comparePropsWithShallowArrayCompare('entryIds') -) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions-entry.tsx deleted file mode 100644 index 95aba0a5ee..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions-entry.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useTranslation } from 'react-i18next' -import EntryContainer from '../entry-container' -import EntryCallout from '../entry-callout' -import BulkActions from './bulk-actions' -import Modal, { useBulkActionsModal } from './modal' -import { ReviewPanelBulkActionsEntry } from '../../../../../../../../types/review-panel/entry' -import MaterialIcon from '@/shared/components/material-icon' - -type BulkActionsEntryProps = { - entryId: ReviewPanelBulkActionsEntry['type'] - nChanges: number -} - -function BulkActionsEntry({ entryId, nChanges }: BulkActionsEntryProps) { - const { t } = useTranslation() - const { - show, - setShow, - isAccept, - handleShowBulkAcceptDialog, - handleShowBulkRejectDialog, - handleConfirmDialog, - } = useBulkActionsModal() - - return ( - <> - - {nChanges > 1 && ( - <> - - - - -  {t('reject_all')} ({nChanges}) - - - -  {t('accept_all')} ({nChanges}) - - - - )} - - - - ) -} - -export default BulkActionsEntry diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions.tsx deleted file mode 100644 index f8201e479e..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import classnames from 'classnames' - -function BulkActions({ - className, - ...rest -}: React.ComponentPropsWithoutRef<'div'>) { - return ( -
- ) -} - -BulkActions.Button = function BulkActionsButton({ - className, - ...rest -}: React.ComponentPropsWithoutRef<'button'>) { - return ( - - )} - -
-
- - {formatTime(timestamp, 'MMM D, Y h:mm A')} - - {user && ( - -  •  - - {user.name ?? t('anonymous')} - - - )} -
- - - {permissions.write && ( - - rejectChanges(entryIds)}> - -  {t('reject')} - - acceptChanges(entryIds)}> - -  {t('accept')} - - - )} - - - ) -} - -export default memo( - ChangeEntry, - comparePropsWithShallowArrayCompare('entryIds') -) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx deleted file mode 100644 index 9987a046d5..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useState, useRef, useEffect, memo } from 'react' -import { useTranslation } from 'react-i18next' -import EntryContainer from './entry-container' -import EntryCallout from './entry-callout' -import EntryActions from './entry-actions' -import Comment from './comment' -import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area' -import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context' -import classnames from 'classnames' -import { ThreadId } from '../../../../../../../types/review-panel/review-panel' -import { Permissions } from '@/features/ide-react/types/permissions' -import { DocId } from '../../../../../../../types/project-settings' -import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread' -import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry' -import useIndicatorHover from '../hooks/use-indicator-hover' -import EntryIndicator from './entry-indicator' -import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click' -import MaterialIcon from '@/shared/components/material-icon' -import LoadingSpinner from '@/shared/components/loading-spinner' - -type CommentEntryProps = { - docId: DocId - entryId: ThreadId - thread: ReviewPanelCommentThread | undefined - threadId: ReviewPanelCommentEntry['thread_id'] - permissions: Permissions -} & Pick - -function CommentEntry({ - docId, - entryId, - thread, - threadId, - offset, - focused, - permissions, -}: CommentEntryProps) { - const { t } = useTranslation() - const { resolveComment, submitReply, handleLayoutChange } = - useReviewPanelUpdaterFnsContext() - const [replyContent, setReplyContent] = useState('') - const [animating, setAnimating] = useState(false) - const [resolved, setResolved] = useState(false) - const entryDivRef = useRef(null) - const { - hoverCoords, - indicatorRef, - endHover, - handleIndicatorMouseEnter, - handleIndicatorClick, - } = useIndicatorHover() - - const handleEntryClick = useEntryClick(docId, offset) - - const handleAnimateAndCallOnResolve = () => { - setAnimating(true) - - if (entryDivRef.current) { - entryDivRef.current.style.top = '0' - } - - setTimeout(() => { - setAnimating(false) - setResolved(true) - resolveComment(docId, entryId) - }, 350) - } - - const handleCommentReplyKeyPress = ( - e: React.KeyboardEvent - ) => { - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - - if (replyContent.length) { - ;(e.target as HTMLTextAreaElement).blur() - submitReply(threadId, replyContent) - setReplyContent('') - } - } - } - - const handleOnReply = () => { - if (replyContent.length) { - submitReply(threadId, replyContent) - setReplyContent('') - } - } - - const submitting = Boolean(thread?.submitting) - - // Update the layout when loading finishes - useEffect(() => { - if (!submitting) { - // Ensure everything is rendered in the DOM before updating the layout. - handleLayoutChange({ async: true }) - } - }, [submitting, handleLayoutChange]) - - if (!thread || resolved) { - return null - } - - return ( - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
- - - - -
- {!submitting && (!thread || thread.messages.length === 0) && ( -
{t('no_comments')}
- )} -
- {thread.messages.map(comment => ( - - ))} -
- {submitting && ( - - )} - {permissions.comment && ( -
- setReplyContent(e.target.value)} - onKeyPress={handleCommentReplyKeyPress} - onClick={e => e.stopPropagation()} - onResize={handleLayoutChange} - placeholder={t('hit_enter_to_reply')} - value={replyContent} - /> -
- )} - - {permissions.comment && permissions.write && ( - - -  {t('resolve')} - - )} - {permissions.comment && ( - - -  {t('reply')} - - )} - -
-
-
- ) -} - -export default memo(CommentEntry) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx deleted file mode 100644 index 80858aca9c..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { memo, useState } from 'react' -import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area' -import { formatTime } from '../../../../utils/format-date' -import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context' -import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread' -import { - ReviewPanelCommentThreadMessage, - ThreadId, -} from '../../../../../../../types/review-panel/review-panel' - -type CommentProps = { - thread: ReviewPanelCommentThread - threadId: ThreadId - comment: ReviewPanelCommentThreadMessage -} - -function Comment({ thread, threadId, comment }: CommentProps) { - const { t } = useTranslation() - const { handleLayoutChange, deleteComment, saveEdit } = - useReviewPanelUpdaterFnsContext() - const [deleting, setDeleting] = useState(false) - const [editing, setEditing] = useState(false) - - const handleConfirmDelete = () => { - setDeleting(true) - handleLayoutChange() - } - - const handleDoDelete = () => { - setDeleting(false) - deleteComment(threadId, comment.id) - handleLayoutChange() - } - - const handleCancelDelete = () => { - setDeleting(false) - handleLayoutChange() - } - - const handleStartEditing = () => { - setEditing(true) - handleLayoutChange() - } - - const handleSaveEditOnEnter = ( - e: React.KeyboardEvent - ) => { - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - handleSaveEdit(e) - } - } - - const handleSaveEdit = ( - e: - | React.FocusEvent - | React.KeyboardEvent - ) => { - setEditing(false) - saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value) - } - - return ( -
-

- {editing ? ( - e.stopPropagation()} - onResize={handleLayoutChange} - autoFocus // eslint-disable-line jsx-a11y/no-autofocus - /> - ) : ( - <> - - {comment.user.name}: - -   - {comment.content} - - )} -

- {!editing && ( -
- {!deleting && formatTime(comment.timestamp, 'MMM D, Y h:mm A')} - {comment.user.isSelf && !deleting && ( - -  •  - - {thread.messages.length > 1 && ( - <> -  •  - - - )} - - )} - {comment.user.isSelf && deleting && ( - - {t('are_you_sure')} •  - -  •  - - - )} -
- )} -
- ) -} - -export default memo(Comment) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx deleted file mode 100644 index 270214d417..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import classnames from 'classnames' - -function EntryActions({ - className, - ...rest -}: React.ComponentPropsWithoutRef<'div'>) { - return
-} - -EntryActions.Button = function EntryActionsButton({ - className, - ...rest -}: React.ComponentPropsWithoutRef<'button'>) { - return ( - - - )} -
- {thread.messages.map((comment, index) => { - const showUser = - index === 0 || - comment.user.id !== thread.messages[index - 1].user.id - - return ( -
-

- {showUser && ( - - {comment.user.name}:  - - )} - - {comment.content} - -

-
- {formatTime(comment.timestamp, 'MMM D, Y h:mm A')} -
-
- ) - })} -
-

- - {thread.resolved_by_user.name}:  - - {t('mark_as_resolved')}. -

-
- {formatTime(thread.resolved_at, 'MMM D, Y h:mm A')} -
-
- - {permissions.comment && permissions.write && ( -
- - -
- )} - - ) -} - -export default memo(ResolvedCommentEntry) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entry.tsx deleted file mode 100644 index 1e68ca4c77..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entry.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { memo } from 'react' -import ChangeEntry from './entries/change-entry' -import AggregateChangeEntry from './entries/aggregate-change-entry' -import CommentEntry from './entries/comment-entry' -import AddCommentEntry from './entries/add-comment-entry' -import BulkActionsEntry from './entries/bulk-actions-entry/bulk-actions-entry' -import { - ReviewPanelDocEntries, - ThreadId, -} from '../../../../../../types/review-panel/review-panel' -import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context' -import { useEditorContext } from '../../../../shared/context/editor-context' - -type Props = { - entry: ReviewPanelDocEntries[keyof ReviewPanelDocEntries] - id: ThreadId | 'add-comment' | 'bulk-actions' -} - -const isEntryAThreadId = ( - entry: keyof ReviewPanelDocEntries -): entry is ThreadId => entry !== 'add-comment' && entry !== 'bulk-actions' - -function Entry({ entry, id }: Props) { - const { - commentThreads, - openDocId, - permissions, - loadingThreads, - users, - nVisibleSelectedChanges: nChanges, - } = useReviewPanelValueContext() - const { isRestrictedTokenMember } = useEditorContext() - - if (!entry.visible || !openDocId) { - return null - } - - if ( - isEntryAThreadId(id) && - (entry.type === 'insert' || entry.type === 'delete') - ) { - return ( - - ) - } - - if (isEntryAThreadId(id) && entry.type === 'aggregate-change') { - return ( - - ) - } - - if (isEntryAThreadId(id) && entry.type === 'comment' && !loadingThreads) { - return ( - - ) - } - - if ( - entry.type === 'add-comment' && - permissions.comment && - !isRestrictedTokenMember - ) { - return - } - - if (entry.type === 'bulk-actions' && permissions.write) { - return ( - - ) - } - - return null -} - -export default memo(Entry) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-entry-click.ts b/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-entry-click.ts deleted file mode 100644 index e2cba91612..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-entry-click.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useReviewPanelUpdaterFnsContext } from '@/features/source-editor/context/review-panel/review-panel-context' -import { DocId } from '../../../../../../../types/project-settings' - -export function useEntryClick( - docId: DocId, - offset: number, - cb?: (e: React.MouseEvent) => void -) { - const { gotoEntry } = useReviewPanelUpdaterFnsContext() - - return (e: React.MouseEvent) => { - const target = e.target as Element - - // Ignore clicks inside interactive elements - if (!target.closest('textarea, button, a')) { - // If the user was making a selection within the entry rather than - // clicking it, ignore the click. Do this by checking whether there is a - // selection that intersects with the target, in which case we assume - // the user was making a selection - const selection = window.getSelection() - if ( - !selection || - selection.isCollapsed || - selection.rangeCount === 0 || - !selection.getRangeAt(0).intersectsNode(target) - ) { - gotoEntry(docId, offset) - } - } - - cb?.(e) - } -} diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-indicator-hover.ts b/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-indicator-hover.ts deleted file mode 100644 index 2e40038d41..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-indicator-hover.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { flushSync } from 'react-dom' -import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context' -import { useLayoutContext } from '../../../../../shared/context/layout-context' -import EntryIndicator from '../entries/entry-indicator' - -export type Coordinates = { - x: number - y: number -} - -function useIndicatorHover() { - const [hoverCoords, setHoverCoords] = useState(null) - const { toggleReviewPanel } = useReviewPanelUpdaterFnsContext() - const { reviewPanelOpen } = useLayoutContext() - const { setLayoutSuspended, handleLayoutChange } = - useReviewPanelUpdaterFnsContext() - const indicatorRef = useRef | null>( - null - ) - - const endHover = useCallback(() => { - if (!reviewPanelOpen) { - // Use flushSync to ensure that React renders immediately. This is - // necessary to ensure that the subsequent layout update acts on the - // updated DOM. - flushSync(() => { - setHoverCoords(null) - setLayoutSuspended(false) - }) - handleLayoutChange({ force: true }) - } - }, [handleLayoutChange, reviewPanelOpen, setLayoutSuspended]) - - const handleIndicatorMouseEnter = () => { - const rect = indicatorRef.current?.getBoundingClientRect() - setHoverCoords({ - x: rect?.left || 0, - y: rect?.top || 0, - }) - setLayoutSuspended(true) - } - - const handleIndicatorClick = () => { - setHoverCoords(null) - setLayoutSuspended(false) - toggleReviewPanel() - } - - useEffect(() => { - if (hoverCoords) { - window.addEventListener('editor:scroll', endHover) - - return () => { - window.removeEventListener('editor:scroll', endHover) - } - } - }, [hoverCoords, endHover]) - - return { - hoverCoords, - indicatorRef, - endHover, - handleIndicatorMouseEnter, - handleIndicatorClick, - } -} - -export default useIndicatorHover diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx deleted file mode 100644 index 6b5dcda26f..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useTranslation } from 'react-i18next' -import classnames from 'classnames' -import { - useReviewPanelValueContext, - useReviewPanelUpdaterFnsContext, -} from '../../context/review-panel/review-panel-context' -import { isCurrentFileView, isOverviewView } from '../../utils/sub-view' -import { useCallback } from 'react' -import { useResizeObserver } from '../../../../shared/hooks/use-resize-observer' -import MaterialIcon from '@/shared/components/material-icon' - -function Nav() { - const { t } = useTranslation() - const { subView } = useReviewPanelValueContext() - const { handleSetSubview, setNavHeight } = useReviewPanelUpdaterFnsContext() - const handleResize = useCallback( - el => { - // Use requestAnimationFrame to prevent errors like "ResizeObserver loop - // completed with undelivered notifications" that occur if onResize does - // something complicated. The cost of this is that onResize lags one frame - // behind, but it's unlikely to matter. - const height = el.offsetHeight - window.requestAnimationFrame(() => setNavHeight(height)) - }, - [setNavHeight] - ) - const { elementRef } = useResizeObserver(handleResize) - - return ( -
- - -
- ) -} - -export default Nav diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx deleted file mode 100644 index cdfb0fa6f0..0000000000 --- a/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Container from './container' -import Toggler from './toggler' -import Toolbar from './toolbar/toolbar' -import Nav from './nav' -import OverviewFile from './overview-file' -import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context' -import { useFileTreeData } from '@/shared/context/file-tree-data-context' -import { memo } from 'react' -import LoadingSpinner from '@/shared/components/loading-spinner' - -function OverviewContainer() { - const { isOverviewLoading } = useReviewPanelValueContext() - const { docs } = useFileTreeData() - - return ( - - - -
- {isOverviewLoading ? ( - - ) : ( - docs?.map(doc => ( - - )) - )} -
-