/* eslint-disable camelcase, max-len, no-return-assign, no-unused-vars, */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS205: Consider reworking code to avoid use of IIFEs * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ import RangesTracker from '@overleaf/ranges-tracker' import App from '../../../base' import EventEmitter from '../../../utils/EventEmitter' import ColorManager from '../../colors/ColorManager' import getMeta from '../../../utils/meta' export default App.controller( 'ReviewPanelController', function ( $scope, $element, ide, $timeout, $http, $modal, eventTracking, localStorage ) { let UserTCSyncState const $reviewPanelEl = $element.find('#review-panel') const UserTypes = { MEMBER: 'member', // Invited, listed in project.members GUEST: 'guest', // Not invited, but logged in so has a user_id ANONYMOUS: 'anonymous', // No user_id } const currentUserType = function () { if ((ide.$scope.user != null ? ide.$scope.user.id : undefined) == null) { return UserTypes.ANONYMOUS } else { const user_id = ide.$scope.user.id const { project } = ide.$scope if ( (project.owner != null ? project.owner.id : undefined) === user_id ) { return UserTypes.MEMBER } for (const member of Array.from(project.members)) { if (member._id === user_id) { return UserTypes.MEMBER } } return UserTypes.GUEST } } $scope.SubViews = { CUR_FILE: 'cur_file', OVERVIEW: 'overview', } $scope.UserTCSyncState = UserTCSyncState = { SYNCED: 'synced', PENDING: 'pending', } ide.$scope.reviewPanel = { trackChangesState: {}, trackChangesOnForEveryone: false, trackChangesOnForGuests: false, trackChangesForGuestsAvailable: false, entries: {}, resolvedComments: {}, hasEntries: false, subView: $scope.SubViews.CUR_FILE, openSubView: $scope.SubViews.CUR_FILE, overview: { loading: false, docsCollapsedState: JSON.parse( localStorage(`docs_collapsed_state:${$scope.project_id}`) ) || {}, }, dropdown: { loading: false, }, commentThreads: {}, resolvedThreadIds: {}, layoutToLeft: false, rendererData: {}, formattedProjectMembers: {}, fullTCStateCollapsed: true, // All selected changes. If a aggregated change (insertion + deletion) is selection, the two ids // will be present. The length of this array will differ from the count below (see explanation). selectedEntryIds: [], // A count of user-facing selected changes. An aggregated change (insertion + deletion) will count // as only one. nVisibleSelectedChanges: 0, entryHover: false, isReact: getMeta('ol-splitTestVariants')?.['review-panel'] === 'react', } ide.$scope.loadingThreads = true window.addEventListener('beforeunload', function () { const collapsedStates = {} for (const doc in ide.$scope.reviewPanel.overview.docsCollapsedState) { const state = ide.$scope.reviewPanel.overview.docsCollapsedState[doc] if (state) { collapsedStates[doc] = state } } const valToStore = Object.keys(collapsedStates).length > 0 ? JSON.stringify(collapsedStates) : null return localStorage( `docs_collapsed_state:${$scope.project_id}`, valToStore ) }) $scope.$on('layout:pdf:linked', (event, state) => ide.$scope.$broadcast('review-panel:layout') ) $scope.$on('layout:pdf:resize', (event, state) => { ide.$scope.reviewPanel.layoutToLeft = state.east?.size < 220 || state.east?.initClosed ide.$scope.$broadcast('review-panel:layout', false) }) $scope.$on('expandable-text-area:resize', event => $timeout(() => ide.$scope.$broadcast('review-panel:layout')) ) $scope.$on('review-panel:sizes', (e, sizes) => { $scope.$broadcast('editor:set-scroll-size', sizes) dispatchReviewPanelEvent('sizes', sizes) }) $scope.$watch('project.features.trackChangesVisible', function (visible) { if (visible == null) { return } if (!visible) { return ($scope.ui.reviewPanelOpen = false) } }) $scope.$watch('project.members', function (members) { ide.$scope.reviewPanel.formattedProjectMembers = {} if (($scope.project != null ? $scope.project.owner : undefined) != null) { ide.$scope.reviewPanel.formattedProjectMembers[ $scope.project.owner._id ] = formatUser($scope.project.owner) } if ( ($scope.project != null ? $scope.project.members : undefined) != null ) { return (() => { const result = [] for (const member of Array.from(members)) { if (member.privileges === 'readAndWrite') { if ( ide.$scope.reviewPanel.trackChangesState[member._id] == null ) { // An added member will have track changes enabled if track changes is on for everyone _setUserTCState( member._id, ide.$scope.reviewPanel.trackChangesOnForEveryone, true ) } result.push( (ide.$scope.reviewPanel.formattedProjectMembers[member._id] = formatUser(member)) ) } else { result.push(undefined) } } return result })() } }) $scope.commentState = { adding: false, content: '', } ide.$scope.users = $scope.users = {} ide.$scope.reviewPanelEventsBridge = new EventEmitter() ide.socket.on('new-comment', function (thread_id, comment) { const thread = getThread(thread_id) delete thread.submitting thread.messages.push(formatComment(comment)) $scope.$apply() return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) }) ide.socket.on('accept-changes', function (doc_id, change_ids) { if (doc_id !== $scope.editor.open_doc_id) { getChangeTracker(doc_id).removeChangeIds(change_ids) } else { $scope.$broadcast('changes:accept', change_ids) dispatchReviewPanelEvent('changes:accept', change_ids) } updateEntries(doc_id) return $scope.$apply(function () {}) }) ide.socket.on('resolve-thread', (thread_id, user) => _onCommentResolved(thread_id, user) ) ide.socket.on('reopen-thread', thread_id => _onCommentReopened(thread_id)) ide.socket.on('delete-thread', function (thread_id) { _onThreadDeleted(thread_id) return $scope.$apply(function () {}) }) ide.socket.on('edit-message', function (thread_id, message_id, content) { _onCommentEdited(thread_id, message_id, content) return $scope.$apply(function () {}) }) ide.socket.on('delete-message', function (thread_id, message_id) { _onCommentDeleted(thread_id, message_id) return $scope.$apply(function () {}) }) const rangesTrackers = {} const getDocEntries = function (doc_id) { if (ide.$scope.reviewPanel.entries[doc_id] == null) { ide.$scope.reviewPanel.entries[doc_id] = {} } return ide.$scope.reviewPanel.entries[doc_id] } const getDocResolvedComments = function (doc_id) { if (ide.$scope.reviewPanel.resolvedComments[doc_id] == null) { ide.$scope.reviewPanel.resolvedComments[doc_id] = {} } return ide.$scope.reviewPanel.resolvedComments[doc_id] } function getThread(thread_id) { if (ide.$scope.reviewPanel.commentThreads[thread_id] == null) { ide.$scope.reviewPanel.commentThreads[thread_id] = { messages: [] } } return ide.$scope.reviewPanel.commentThreads[thread_id] } function getChangeTracker(doc_id) { if (rangesTrackers[doc_id] == null) { rangesTrackers[doc_id] = new RangesTracker() rangesTrackers[doc_id].resolvedThreadIds = ide.$scope.reviewPanel.resolvedThreadIds } return rangesTrackers[doc_id] } let scrollbar = {} ide.$scope.reviewPanelEventsBridge.on( 'aceScrollbarVisibilityChanged', function (isVisible, scrollbarWidth) { scrollbar = { isVisible, scrollbarWidth } return updateScrollbar() } ) function updateScrollbar() { if ( scrollbar.isVisible && ide.$scope.reviewPanel.subView === $scope.SubViews.CUR_FILE && !$scope.editor.showRichText ) { return $reviewPanelEl.css('right', `${scrollbar.scrollbarWidth}px`) } else { return $reviewPanelEl.css('right', '0') } } $scope.$watch( '!ui.reviewPanelOpen && reviewPanel.hasEntries', function (open, prevVal) { if (open == null) { return } $scope.ui.miniReviewPanelVisible = open if (open !== prevVal) { return $timeout(() => $scope.$broadcast('review-panel:toggle')) } } ) $scope.$watch('ui.reviewPanelOpen', function (open) { if (open == null) { return } if (!open) { // Always show current file when not open, but save current state ide.$scope.reviewPanel.openSubView = ide.$scope.reviewPanel.subView ide.$scope.reviewPanel.subView = $scope.SubViews.CUR_FILE } else { // Reset back to what we had when previously open ide.$scope.reviewPanel.subView = ide.$scope.reviewPanel.openSubView } return $timeout(function () { $scope.$broadcast('review-panel:toggle') return ide.$scope.$broadcast('review-panel:layout', false) }) }) $scope.$watch('reviewPanel.subView', function (view, oldView) { if (view == null) { return } updateScrollbar() if (view === $scope.SubViews.OVERVIEW) { return refreshOverviewPanel() } else if (oldView === $scope.SubViews.OVERVIEW) { dispatchReviewPanelEvent('overview-closed', view) } }) $scope.$watch('editor.sharejs_doc', function (doc, old_doc) { if (doc == null) { return } // The open doc range tracker is kept up to date in real-time so // replace any outdated info with this rangesTrackers[doc.doc_id] = doc.ranges rangesTrackers[doc.doc_id].resolvedThreadIds = ide.$scope.reviewPanel.resolvedThreadIds ide.$scope.reviewPanel.rangesTracker = rangesTrackers[doc.doc_id] if (old_doc != null) { old_doc.off('flipped_pending_to_inflight') } doc.on('flipped_pending_to_inflight', () => regenerateTrackChangesId(doc)) return regenerateTrackChangesId(doc) }) $scope.$watch( function () { const entries = ide.$scope.reviewPanel.entries[$scope.editor.open_doc_id] || {} const permEntries = {} for (const entry in entries) { const entryData = entries[entry] if (!['add-comment', 'bulk-actions'].includes(entry)) { permEntries[entry] = entryData } } return Object.keys(permEntries).length }, nEntries => (ide.$scope.reviewPanel.hasEntries = nEntries > 0 && $scope.project.features.trackChangesVisible) ) function regenerateTrackChangesId(doc) { const old_id = getChangeTracker(doc.doc_id).getIdSeed() const new_id = RangesTracker.generateIdSeed() getChangeTracker(doc.doc_id).setIdSeed(new_id) return doc.setTrackChangesIdSeeds({ pending: new_id, inflight: old_id }) } const refreshRanges = () => $http .get(`/project/${$scope.project_id}/ranges`) .then(function (response) { const docs = response.data return (() => { const result = [] for (const doc of Array.from(docs)) { if ( ide.$scope.reviewPanel.overview.docsCollapsedState[doc.id] == null ) { ide.$scope.reviewPanel.overview.docsCollapsedState[ doc.id ] = false } if (doc.id !== $scope.editor.open_doc_id) { // this is kept up to date in real-time, don't overwrite const rangesTracker = getChangeTracker(doc.id) rangesTracker.comments = (doc.ranges != null ? doc.ranges.comments : undefined) || [] rangesTracker.changes = (doc.ranges != null ? doc.ranges.changes : undefined) || [] } result.push(updateEntries(doc.id)) } return result })() }) function refreshOverviewPanel() { ide.$scope.reviewPanel.overview.loading = true return refreshRanges() .then(() => (ide.$scope.reviewPanel.overview.loading = false)) .catch(() => (ide.$scope.reviewPanel.overview.loading = false)) } ide.$scope.refreshResolvedCommentsDropdown = function () { ide.$scope.reviewPanel.dropdown.loading = true const q = refreshRanges() q.then(() => (ide.$scope.reviewPanel.dropdown.loading = false)) q.catch(() => (ide.$scope.reviewPanel.dropdown.loading = false)) return q } function updateEntries(doc_id) { const rangesTracker = getChangeTracker(doc_id) const entries = getDocEntries(doc_id) const resolvedComments = getDocResolvedComments(doc_id) let changed = false // Assume we'll delete everything until we see it, then we'll remove it from this object const delete_changes = {} for (const id in entries) { const change = entries[id] if (!['add-comment', 'bulk-actions'].includes(id)) { for (const entry_id of Array.from(change.entry_ids)) { delete_changes[entry_id] = true } } } for (const id in resolvedComments) { const change = resolvedComments[id] for (const entry_id of Array.from(change.entry_ids)) { delete_changes[entry_id] = true } } let potential_aggregate = false let prev_insertion = null for (const change of Array.from(rangesTracker.changes)) { changed = true if ( potential_aggregate && change.op.d && change.op.p === prev_insertion.op.p + prev_insertion.op.i.length && change.metadata.user_id === prev_insertion.metadata.user_id ) { // An actual aggregate op. entries[prev_insertion.id].type = 'aggregate-change' entries[prev_insertion.id].metadata.replaced_content = change.op.d entries[prev_insertion.id].entry_ids.push(change.id) } else { if (entries[change.id] == null) { entries[change.id] = {} } delete delete_changes[change.id] const new_entry = { 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 in new_entry) { entries[change.id][key] = new_entry[key] } } if (change.op.i) { potential_aggregate = true prev_insertion = change } else { potential_aggregate = false prev_insertion = null } if ($scope.users[change.metadata.user_id] == null) { if (!window.isRestrictedTokenMember) { refreshChangeUsers(change.metadata.user_id) } } } if (!window.isRestrictedTokenMember) { if (rangesTracker.comments.length > 0) { ensureThreadsAreLoaded() } else if (ide.$scope.loadingThreads === true) { // ensure that tracked changes are highlighted even if no comments are loaded ide.$scope.loadingThreads = false dispatchReviewPanelEvent('loaded_threads') } } for (const comment of Array.from(rangesTracker.comments)) { let new_comment changed = true delete delete_changes[comment.id] if (ide.$scope.reviewPanel.resolvedThreadIds[comment.op.t]) { new_comment = resolvedComments[comment.id] != null ? resolvedComments[comment.id] : (resolvedComments[comment.id] = {}) delete entries[comment.id] } else { new_comment = entries[comment.id] != null ? entries[comment.id] : (entries[comment.id] = {}) delete resolvedComments[comment.id] } const new_entry = { type: 'comment', thread_id: comment.op.t, entry_ids: [comment.id], content: comment.op.c, offset: comment.op.p, } for (const key in new_entry) { new_comment[key] = new_entry[key] } } for (const change_id in delete_changes) { const _ = delete_changes[change_id] changed = true delete entries[change_id] delete resolvedComments[change_id] } if (changed) { // TODO: unused? $scope.$broadcast('entries:changed') } return entries } $scope.$on('editor:track-changes:changed', function () { const doc_id = $scope.editor.open_doc_id const entries = updateEntries(doc_id) // For now, not worrying about entry panels for rich text if (!$scope.editor.showRichText) { $scope.$broadcast('review-panel:recalculate-screen-positions') dispatchReviewPanelEvent('recalculate-screen-positions', entries) return ide.$scope.$broadcast('review-panel:layout') } }) $scope.$on('editor:track-changes:visibility_changed', () => $timeout(() => ide.$scope.$broadcast('review-panel:layout', false)) ) $scope.$on( 'editor:focus:changed', function (e, selection_offset_start, selection_offset_end, selection) { const doc_id = $scope.editor.open_doc_id const entries = getDocEntries(doc_id) // All selected changes will be added to this array. ide.$scope.reviewPanel.selectedEntryIds = [] // Count of user-visible changes, i.e. an aggregated change will count as one. ide.$scope.reviewPanel.nVisibleSelectedChanges = 0 const offset = selection_offset_start const length = selection_offset_end - selection_offset_start // 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 = entries['add-comment'] if ( !existingAddComment || existingAddComment.offset !== offset || existingAddComment.length !== length ) { entries['add-comment'] = { type: 'add-comment', offset, length, } } const existingBulkActions = entries['bulk-actions'] if ( !existingBulkActions || existingBulkActions.offset !== offset || existingBulkActions.length !== length ) { entries['bulk-actions'] = { type: 'bulk-actions', offset, length, } } } else { delete entries['add-comment'] delete entries['bulk-actions'] } for (const id in entries) { const entry = entries[id] let isChangeEntryAndWithinSelection = false if ( entry.type === 'comment' && !ide.$scope.reviewPanel.resolvedThreadIds[entry.thread_id] ) { entry.focused = entry.offset <= selection_offset_start && selection_offset_start <= entry.offset + entry.content.length } else if (entry.type === 'insert') { isChangeEntryAndWithinSelection = entry.offset >= selection_offset_start && entry.offset + entry.content.length <= selection_offset_end entry.focused = entry.offset <= selection_offset_start && selection_offset_start <= entry.offset + entry.content.length } else if (entry.type === 'delete') { isChangeEntryAndWithinSelection = selection_offset_start <= entry.offset && entry.offset <= selection_offset_end entry.focused = entry.offset === selection_offset_start } else if (entry.type === 'aggregate-change') { isChangeEntryAndWithinSelection = entry.offset >= selection_offset_start && entry.offset + entry.content.length <= selection_offset_end entry.focused = entry.offset <= selection_offset_start && selection_offset_start <= entry.offset + entry.content.length } else if ( ['add-comment', 'bulk-actions'].includes(entry.type) && selection ) { entry.focused = true } if (isChangeEntryAndWithinSelection) { for (const entry_id of Array.from(entry.entry_ids)) { ide.$scope.reviewPanel.selectedEntryIds.push(entry_id) } ide.$scope.reviewPanel.nVisibleSelectedChanges++ } } $scope.$broadcast('review-panel:recalculate-screen-positions') dispatchReviewPanelEvent('recalculate-screen-positions', entries) // Ensure that watchers, such as the React-based review panel component, // are informed of the changes to entries ide.$scope.$apply() return ide.$scope.$broadcast('review-panel:layout') } ) ide.$scope.acceptChanges = function (change_ids) { _doAcceptChanges(change_ids) eventTracking.sendMB('rp-changes-accepted', { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', }) } ide.$scope.rejectChanges = function (change_ids) { _doRejectChanges(change_ids) eventTracking.sendMB('rp-changes-rejected', { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', }) } // The next two functions control a class on the review panel that affects // the overflow-y rule on the panel. This is necessary so that an entry in // the review panel is visible when hovering over its indicator when the // review panel is minimized. See issue #8057. $scope.mouseEnterIndicator = function () { ide.$scope.reviewPanel.entryHover = true } $scope.mouseLeaveIndicator = function () { ide.$scope.reviewPanel.entryHover = false } function _doAcceptChanges(change_ids) { $http.post( `/project/${$scope.project_id}/doc/${$scope.editor.open_doc_id}/changes/accept`, { change_ids, _csrf: window.csrfToken } ) $scope.$broadcast('changes:accept', change_ids) dispatchReviewPanelEvent('changes:accept', change_ids) } const _doRejectChanges = change_ids => { $scope.$broadcast('changes:reject', change_ids) dispatchReviewPanelEvent('changes:reject', change_ids) } ide.$scope.bulkAcceptActions = function () { _doAcceptChanges(ide.$scope.reviewPanel.selectedEntryIds.slice()) eventTracking.sendMB('rp-bulk-accept', { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', nEntries: ide.$scope.reviewPanel.nVisibleSelectedChanges, }) } ide.$scope.bulkRejectActions = function () { _doRejectChanges(ide.$scope.reviewPanel.selectedEntryIds.slice()) eventTracking.sendMB('rp-bulk-reject', { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', nEntries: ide.$scope.reviewPanel.nVisibleSelectedChanges, }) } ide.$scope.showBulkAcceptDialog = () => showBulkActionsDialog(true) ide.$scope.showBulkRejectDialog = () => showBulkActionsDialog(false) const showBulkActionsDialog = isAccept => $modal .open({ templateUrl: 'bulkActionsModalTemplate', controller: 'BulkActionsModalController', resolve: { isAccept() { return isAccept }, nChanges() { return ide.$scope.reviewPanel.nVisibleSelectedChanges }, }, scope: $scope.$new(), }) .result.then(function (isAccept) { if (isAccept) { return ide.$scope.bulkAcceptActions() } else { return ide.$scope.bulkRejectActions() } }) $scope.handleTogglerClick = function (e) { e.target.blur() return $scope.toggleReviewPanel() } ide.$scope.addNewComment = function (e) { e.preventDefault() $scope.$broadcast('comment:start_adding') return $scope.toggleReviewPanel() } $scope.addNewCommentFromKbdShortcut = function () { if (!$scope.project.features.trackChangesVisible) { return } $scope.$broadcast('comment:select_line') dispatchReviewPanelEvent('comment:select_line') if (!$scope.ui.reviewPanelOpen) { $scope.toggleReviewPanel() } return $timeout(function () { ide.$scope.$broadcast('review-panel:layout') return $scope.$broadcast('comment:start_adding') }) } $scope.startNewComment = function () { $scope.$broadcast('comment:select_line') dispatchReviewPanelEvent('comment:select_line') return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) } ide.$scope.submitNewComment = function (content) { if (content == null || content === '') { return } const doc_id = $scope.editor.open_doc_id const entries = getDocEntries(doc_id) if (entries['add-comment'] == null) { return } const { offset, length } = entries['add-comment'] const thread_id = RangesTracker.generateId() const thread = getThread(thread_id) thread.submitting = true $scope.$broadcast('comment:add', thread_id, offset, length) dispatchReviewPanelEvent('comment:add', { threadId: thread_id, offset, length, }) $http .post(`/project/${$scope.project_id}/thread/${thread_id}/messages`, { content, _csrf: window.csrfToken, }) .catch(() => ide.showGenericMessageModal( 'Error submitting comment', 'Sorry, there was a problem submitting your comment' ) ) // TODO: unused? $scope.$broadcast('editor:clearSelection') $timeout(() => ide.$scope.$broadcast('review-panel:layout')) eventTracking.sendMB('rp-new-comment', { size: content.length }) } $scope.cancelNewComment = entry => $timeout(() => ide.$scope.$broadcast('review-panel:layout')) $scope.startReply = function (entry) { entry.replying = true return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) } ide.$scope.submitReply = function (entry, entry_id) { const { thread_id } = entry const content = entry.replyContent $http .post(`/project/${$scope.project_id}/thread/${thread_id}/messages`, { content, _csrf: window.csrfToken, }) .catch(() => ide.showGenericMessageModal( 'Error submitting comment', 'Sorry, there was a problem submitting your comment' ) ) const trackingMetadata = { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', size: entry.replyContent.length, thread: thread_id, } const thread = getThread(thread_id) thread.submitting = true entry.replyContent = '' entry.replying = false $timeout(() => ide.$scope.$broadcast('review-panel:layout')) eventTracking.sendMB('rp-comment-reply', trackingMetadata) } $scope.cancelReply = function (entry) { entry.replying = false entry.replyContent = '' return ide.$scope.$broadcast('review-panel:layout') } ide.$scope.resolveComment = function (doc_id, entry_id) { const entry = getDocEntries(doc_id)[entry_id] entry.focused = false $http.post( `/project/${$scope.project_id}/thread/${entry.thread_id}/resolve`, { _csrf: window.csrfToken } ) _onCommentResolved(entry.thread_id, ide.$scope.user) eventTracking.sendMB('rp-comment-resolve', { view: $scope.ui.reviewPanelOpen ? ide.$scope.reviewPanel.subView : 'mini', }) } ide.$scope.unresolveComment = function (thread_id) { _onCommentReopened(thread_id) $http.post(`/project/${$scope.project_id}/thread/${thread_id}/reopen`, { _csrf: window.csrfToken, }) eventTracking.sendMB('rp-comment-reopen') } function _onCommentResolved(thread_id, user) { const thread = getThread(thread_id) if (thread == null) { return } thread.resolved = true thread.resolved_by_user = formatUser(user) thread.resolved_at = new Date().toISOString() ide.$scope.reviewPanel.resolvedThreadIds[thread_id] = true $scope.$broadcast('comment:resolve_threads', [thread_id]) dispatchReviewPanelEvent('comment:resolve_threads', [thread_id]) } function _onCommentReopened(thread_id) { const thread = getThread(thread_id) if (thread == null) { return } delete thread.resolved delete thread.resolved_by_user delete thread.resolved_at delete ide.$scope.reviewPanel.resolvedThreadIds[thread_id] $scope.$broadcast('comment:unresolve_thread', thread_id) dispatchReviewPanelEvent('comment:unresolve_thread', thread_id) } function _onThreadDeleted(thread_id) { delete ide.$scope.reviewPanel.resolvedThreadIds[thread_id] delete ide.$scope.reviewPanel.commentThreads[thread_id] $scope.$broadcast('comment:remove', thread_id) dispatchReviewPanelEvent('comment:remove', thread_id) } function _onCommentEdited(thread_id, comment_id, content) { const thread = getThread(thread_id) if (thread == null) { return } for (const message of Array.from(thread.messages)) { if (message.id === comment_id) { message.content = content } } return updateEntries() } function _onCommentDeleted(thread_id, comment_id) { const thread = getThread(thread_id) if (thread == null) { return } thread.messages = thread.messages.filter(m => m.id !== comment_id) return updateEntries() } ide.$scope.deleteThread = function (entry_id, doc_id, thread_id) { _onThreadDeleted(thread_id) $http({ method: 'DELETE', url: `/project/${$scope.project_id}/doc/${doc_id}/thread/${thread_id}`, headers: { 'X-CSRF-Token': window.csrfToken, }, }) eventTracking.sendMB('rp-comment-delete') } ide.$scope.saveEdit = function (thread_id, comment_id, content) { $http.post( `/project/${$scope.project_id}/thread/${thread_id}/messages/${comment_id}/edit`, { content, _csrf: window.csrfToken, } ) return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) } ide.$scope.deleteComment = function (thread_id, comment_id) { _onCommentDeleted(thread_id, comment_id) $http({ method: 'DELETE', url: `/project/${$scope.project_id}/thread/${thread_id}/messages/${comment_id}`, headers: { 'X-CSRF-Token': window.csrfToken, }, }) return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) } $scope.setSubView = function (subView) { ide.$scope.reviewPanel.subView = subView eventTracking.sendMB('rp-subview-change', { subView }) } ide.$scope.gotoEntry = (doc_id, entry_offset) => ide.editorManager.openDocId(doc_id, { gotoOffset: entry_offset }) $scope.toggleFullTCStateCollapse = function () { if ($scope.project.features.trackChanges) { return (ide.$scope.reviewPanel.fullTCStateCollapsed = !ide.$scope.reviewPanel.fullTCStateCollapsed) } else { _sendAnalytics() return $scope.openTrackChangesUpgradeModal() } } const _sendAnalytics = () => { eventTracking.send( 'subscription-funnel', 'editor-click-feature', 'real-time-track-changes' ) eventTracking.sendMB('paywall-prompt', { 'paywall-type': 'track-changes', }) } const _setUserTCState = function (userId, newValue, isLocal) { if (isLocal == null) { isLocal = false } if (ide.$scope.reviewPanel.trackChangesState[userId] == null) { ide.$scope.reviewPanel.trackChangesState[userId] = {} } const state = ide.$scope.reviewPanel.trackChangesState[userId] if ( state.syncState == null || state.syncState === UserTCSyncState.SYNCED ) { state.value = newValue state.syncState = UserTCSyncState.SYNCED } else if ( state.syncState === UserTCSyncState.PENDING && state.value === newValue ) { state.syncState = UserTCSyncState.SYNCED } else if (isLocal) { state.value = newValue state.syncState = UserTCSyncState.PENDING } if (userId === ide.$scope.user.id) { return ($scope.editor.wantTrackChanges = newValue) } } const _setEveryoneTCState = function (newValue, isLocal) { if (isLocal == null) { isLocal = false } ide.$scope.reviewPanel.trackChangesOnForEveryone = newValue const { project } = $scope for (const member of Array.from(project.members)) { _setUserTCState(member._id, newValue, isLocal) } _setGuestsTCState(newValue, isLocal) return _setUserTCState(project.owner._id, newValue, isLocal) } function _setGuestsTCState(newValue, isLocal) { if (isLocal == null) { isLocal = false } ide.$scope.reviewPanel.trackChangesOnForGuests = newValue if ( currentUserType() === UserTypes.GUEST || currentUserType() === UserTypes.ANONYMOUS ) { return ($scope.editor.wantTrackChanges = newValue) } } const applyClientTrackChangesStateToServer = function () { const data = {} if (ide.$scope.reviewPanel.trackChangesOnForEveryone) { data.on = true } else { data.on_for = {} for (const userId in ide.$scope.reviewPanel.trackChangesState) { const userState = ide.$scope.reviewPanel.trackChangesState[userId] data.on_for[userId] = userState.value } if (ide.$scope.reviewPanel.trackChangesOnForGuests) { data.on_for_guests = true } } data._csrf = window.csrfToken return $http.post(`/project/${$scope.project_id}/track_changes`, data) } const applyTrackChangesStateToClient = function (state) { if (typeof state === 'boolean') { _setEveryoneTCState(state) return _setGuestsTCState(state) } else { const { project } = $scope ide.$scope.reviewPanel.trackChangesOnForEveryone = false _setGuestsTCState(state.__guests__ === true) for (const member of Array.from(project.members)) { _setUserTCState( member._id, state[member._id] != null ? state[member._id] : false ) } return _setUserTCState( $scope.project.owner._id, state[$scope.project.owner._id] != null ? state[$scope.project.owner._id] : false ) } } ide.$scope.toggleTrackChangesForEveryone = function (onForEveryone) { _setEveryoneTCState(onForEveryone, true) _setGuestsTCState(onForEveryone, true) return applyClientTrackChangesStateToServer() } ide.$scope.toggleTrackChangesForGuests = function (onForGuests) { _setGuestsTCState(onForGuests, true) return applyClientTrackChangesStateToServer() } ide.$scope.toggleTrackChangesForUser = function (onForUser, userId) { _setUserTCState(userId, onForUser, true) return applyClientTrackChangesStateToServer() } ide.socket.on('toggle-track-changes', state => $scope.$apply(() => applyTrackChangesStateToClient(state)) ) $scope.toggleTrackChangesFromKbdShortcut = function () { if ( !( $scope.project.features.trackChangesVisible && $scope.project.features.trackChanges ) ) { return } return $scope.toggleTrackChangesForUser( !ide.$scope.reviewPanel.trackChangesState[ide.$scope.user.id].value, ide.$scope.user.id ) } const setGuestFeatureBasedOnProjectAccessLevel = projectPublicAccessLevel => (ide.$scope.reviewPanel.trackChangesForGuestsAvailable = projectPublicAccessLevel === 'tokenBased') const onToggleTrackChangesForGuestsAvailability = function (available) { // If the feature is no longer available we need to turn off the guest flag if (available) { return } if (!ide.$scope.reviewPanel.trackChangesOnForGuests) { return } // Already turned off if (ide.$scope.reviewPanel.trackChangesOnForEveryone) { return } // Overrides guest setting return $scope.toggleTrackChangesForGuests(false) } $scope.$watch( 'project.publicAccesLevel', setGuestFeatureBasedOnProjectAccessLevel ) $scope.$watch( 'reviewPanel.trackChangesForGuestsAvailable', function (available) { if (available != null) { return onToggleTrackChangesForGuestsAvailability(available) } } ) let _inited = false ide.$scope.$on('project:joined', function () { if (_inited) { return } const { project } = ide.$scope if (project.features.trackChanges) { applyTrackChangesStateToClient(project.trackChangesState) } else { applyTrackChangesStateToClient(false) } setGuestFeatureBasedOnProjectAccessLevel(project.publicAccesLevel) return (_inited = true) }) let _refreshingRangeUsers = false const _refreshedForUserIds = {} function refreshChangeUsers(refresh_for_user_id) { if (refresh_for_user_id != null) { if (_refreshedForUserIds[refresh_for_user_id] != null) { // We've already tried to refresh to get this user id, so stop it looping return } _refreshedForUserIds[refresh_for_user_id] = true } // Only do one refresh at once if (_refreshingRangeUsers) { return } _refreshingRangeUsers = true return $http .get(`/project/${$scope.project_id}/changes/users`) .then(function (response) { const users = response.data _refreshingRangeUsers = false ide.$scope.users = $scope.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 ( (ide.$scope.user != null ? ide.$scope.user.id : undefined) != null ) { $scope.users[ide.$scope.user.id] = formatUser(ide.$scope.user) } return (() => { const result = [] for (const user of Array.from(users)) { if (user.id != null) { result.push(($scope.users[user.id] = formatUser(user))) } else { result.push(undefined) } } return result })() }) .catch(() => (_refreshingRangeUsers = false)) } let _threadsLoaded = false function ensureThreadsAreLoaded() { if (_threadsLoaded) { // We get any updates in real time so only need to load them once. return } _threadsLoaded = true ide.$scope.loadingThreads = true return $http .get(`/project/${$scope.project_id}/threads`) .then(function (response) { const threads = response.data ide.$scope.loadingThreads = false for (const thread_id in ide.$scope.reviewPanel.resolvedThreadIds) { delete ide.$scope.reviewPanel.resolvedThreadIds[thread_id] } for (const thread_id in threads) { const thread = threads[thread_id] for (const comment of Array.from(thread.messages)) { formatComment(comment) } if (thread.resolved_by_user != null) { thread.resolved_by_user = formatUser(thread.resolved_by_user) ide.$scope.reviewPanel.resolvedThreadIds[thread_id] = true $scope.$broadcast('comment:resolve_threads', [thread_id]) } } ide.$scope.reviewPanel.commentThreads = threads dispatchReviewPanelEvent('loaded_threads') return $timeout(() => ide.$scope.$broadcast('review-panel:layout')) }) } function formatComment(comment) { comment.user = formatUser(comment.user) comment.timestamp = new Date(comment.timestamp) return comment } function formatUser(user) { 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: ColorManager.ANONYMOUS_HUE, avatar_text: 'A', } } if (id === window.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: ColorManager.getHueForUserId(id), avatar_text: [user.first_name, user.last_name] .filter(n => n != null) .map(n => n[0]) .join(''), } } $scope.openTrackChangesUpgradeModal = () => $modal.open({ templateUrl: 'trackChangesUpgradeModalTemplate', controller: 'TrackChangesUpgradeModalController', scope: $scope.$new(), }) // listen for events from the CodeMirror 6 track changes extension window.addEventListener('editor:event', event => { const { type, payload } = event.detail switch (type) { case 'line-height': { ide.$scope.reviewPanel.rendererData.lineHeight = payload ide.$scope.$broadcast('review-panel:layout') break } case 'track-changes:changed': { $scope.$broadcast('editor:track-changes:changed') break } case 'track-changes:visibility_changed': { $scope.$broadcast('editor:track-changes:visibility_changed') break } case 'focus:changed': { const { from, to, empty } = payload $scope.$broadcast('editor:focus:changed', from, to, !empty) break } case 'add-new-comment': { $scope.addNewCommentFromKbdShortcut() break } case 'toggle-track-changes': { $scope.toggleTrackChangesFromKbdShortcut() break } case 'toggle-review-panel': { $scope.toggleReviewPanel() break } } }) // Add methods somewhere that React can see them $scope.reviewPanel.saveEdit = $scope.saveEdit } ) // send events to the CodeMirror 6 track changes extension const dispatchReviewPanelEvent = (type, payload) => { window.dispatchEvent( new CustomEvent('review-panel:event', { detail: { type, payload }, }) ) }