overleaf/services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js
Tim Down 5cce9683fa Merge pull request #14628 from overleaf/td-review-panel-scroll-jump
Fix scroll jump and improvements to React review panel layout and performance

GitOrigin-RevId: 3123f90abeb1bf9712ba4025a272e13990aebbd9
2023-09-06 08:06:32 +00:00

1410 lines
45 KiB
JavaScript

/* 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
) {
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)
$scope.$broadcast('review-panel:recalculate-screen-positions')
dispatchReviewPanelEvent('recalculate-screen-positions', {
entries,
updateType: 'trackedChangesChange',
})
// Ensure that watchers, such as the React-based review panel component,
// are informed of the changes to entries
ide.$scope.$apply()
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,
updateType = null
) {
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,
updateType,
})
// 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
// Update entries so that the view has up-to-date information about
// the entries when handling the loaded_threads events, which avoids
// thrashing
updateEntries($scope.editor.open_doc_id)
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, updateType } = payload
$scope.$broadcast(
'editor:focus:changed',
from,
to,
!empty,
updateType
)
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 },
})
)
}