use-review-panel-state.ts 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630
  1. import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { isEqual, cloneDeep } from 'lodash'
  4. import usePersistedState from '@/shared/hooks/use-persisted-state'
  5. import useScopeValue from '../../../../../shared/hooks/use-scope-value'
  6. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  7. import useAbortController from '@/shared/hooks/use-abort-controller'
  8. import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
  9. import useLayoutToLeft from '@/features/ide-react/context/review-panel/hooks/useLayoutToLeft'
  10. import { sendMB } from '@/infrastructure/event-tracking'
  11. import {
  12. dispatchReviewPanelLayout as handleLayoutChange,
  13. UpdateType,
  14. } from '@/features/source-editor/extensions/changes/change-manager'
  15. import { useProjectContext } from '@/shared/context/project-context'
  16. import { useLayoutContext } from '@/shared/context/layout-context'
  17. import { useUserContext } from '@/shared/context/user-context'
  18. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  19. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  20. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  21. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  22. import {
  23. EditorManager,
  24. useEditorManagerContext,
  25. } from '@/features/ide-react/context/editor-manager-context'
  26. import { debugConsole } from '@/utils/debugging'
  27. import { useEditorContext } from '@/shared/context/editor-context'
  28. import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
  29. import ColorManager from '@/ide/colors/ColorManager'
  30. import RangesTracker from '@overleaf/ranges-tracker'
  31. import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
  32. import {
  33. CommentId,
  34. ReviewPanelCommentThreadMessage,
  35. ReviewPanelCommentThreads,
  36. ReviewPanelDocEntries,
  37. SubView,
  38. ThreadId,
  39. } from '../../../../../../../types/review-panel/review-panel'
  40. import { UserId } from '../../../../../../../types/user'
  41. import { PublicAccessLevel } from '../../../../../../../types/public-access-level'
  42. import {
  43. DeepReadonly,
  44. Entries,
  45. MergeAndOverride,
  46. } from '../../../../../../../types/utils'
  47. import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread'
  48. import { DocId } from '../../../../../../../types/project-settings'
  49. import {
  50. ReviewPanelAddCommentEntry,
  51. ReviewPanelAggregateChangeEntry,
  52. ReviewPanelBulkActionsEntry,
  53. ReviewPanelChangeEntry,
  54. ReviewPanelCommentEntry,
  55. ReviewPanelEntry,
  56. } from '../../../../../../../types/review-panel/entry'
  57. import {
  58. ReviewPanelCommentThreadMessageApi,
  59. ReviewPanelCommentThreadsApi,
  60. } from '../../../../../../../types/review-panel/api'
  61. import { DateString } from '../../../../../../../types/helpers/date'
  62. import {
  63. Change,
  64. CommentOperation,
  65. EditOperation,
  66. } from '../../../../../../../types/change'
  67. import { RangesTrackerWithResolvedThreadIds } from '@/features/ide-react/editor/document-container'
  68. const dispatchReviewPanelEvent = (type: string, payload?: any) => {
  69. window.dispatchEvent(
  70. new CustomEvent('review-panel:event', {
  71. detail: { type, payload },
  72. })
  73. )
  74. }
  75. const formatUser = (user: any): any => {
  76. let isSelf, name
  77. const id =
  78. (user != null ? user._id : undefined) ||
  79. (user != null ? user.id : undefined)
  80. if (id == null) {
  81. return {
  82. email: null,
  83. name: 'Anonymous',
  84. isSelf: false,
  85. hue: ColorManager.ANONYMOUS_HUE,
  86. avatar_text: 'A',
  87. }
  88. }
  89. if (id === window.user_id) {
  90. name = 'You'
  91. isSelf = true
  92. } else {
  93. name = [user.first_name, user.last_name]
  94. .filter(n => n != null && n !== '')
  95. .join(' ')
  96. if (name === '') {
  97. name =
  98. (user.email != null ? user.email.split('@')[0] : undefined) || 'Unknown'
  99. }
  100. isSelf = false
  101. }
  102. return {
  103. id,
  104. email: user.email,
  105. name,
  106. isSelf,
  107. hue: ColorManager.getHueForUserId(id),
  108. avatar_text: [user.first_name, user.last_name]
  109. .filter(n => n != null)
  110. .map(n => n[0])
  111. .join(''),
  112. }
  113. }
  114. const formatComment = (
  115. comment: ReviewPanelCommentThreadMessageApi
  116. ): ReviewPanelCommentThreadMessage => {
  117. const commentTyped = comment as unknown as ReviewPanelCommentThreadMessage
  118. commentTyped.user = formatUser(comment.user)
  119. commentTyped.timestamp = new Date(comment.timestamp)
  120. return commentTyped
  121. }
  122. function useReviewPanelState(): ReviewPanel.ReviewPanelState {
  123. const { t } = useTranslation()
  124. const { reviewPanelOpen, setReviewPanelOpen, setMiniReviewPanelVisible } =
  125. useLayoutContext()
  126. const { projectId } = useIdeReactContext()
  127. const project = useProjectContext()
  128. const user = useUserContext()
  129. const { socket } = useConnectionContext()
  130. const {
  131. features: { trackChangesVisible, trackChanges },
  132. } = project
  133. const { isRestrictedTokenMember } = useEditorContext()
  134. const {
  135. openDocId,
  136. currentDocument,
  137. currentDocumentId,
  138. wantTrackChanges,
  139. setWantTrackChanges,
  140. } = useEditorManagerContext() as MergeAndOverride<
  141. EditorManager,
  142. { currentDocumentId: DocId }
  143. >
  144. // TODO permissions to be removed from the review panel context. It currently acts just as a proxy.
  145. const permissions = usePermissionsContext()
  146. const { showGenericMessageModal } = useModalsContext()
  147. const addCommentEmitter = useScopeEventEmitter('comment:start_adding')
  148. const layoutToLeft = useLayoutToLeft('.ide-react-editor-panel')
  149. const [subView, setSubView] =
  150. useState<ReviewPanel.Value<'subView'>>('cur_file')
  151. const [isOverviewLoading, setIsOverviewLoading] =
  152. useState<ReviewPanel.Value<'isOverviewLoading'>>(false)
  153. // All selected changes. If an aggregated change (insertion + deletion) is selected, the two ids
  154. // will be present. The length of this array will differ from the count below (see explanation).
  155. const selectedEntryIds = useRef<ThreadId[]>([])
  156. // A count of user-facing selected changes. An aggregated change (insertion + deletion) will count
  157. // as only one.
  158. const [nVisibleSelectedChanges, setNVisibleSelectedChanges] =
  159. useState<ReviewPanel.Value<'nVisibleSelectedChanges'>>(0)
  160. const [collapsed, setCollapsed] = usePersistedState<
  161. ReviewPanel.Value<'collapsed'>
  162. >(`docs_collapsed_state:${projectId}`, {}, false, true)
  163. const [commentThreads, setCommentThreads] = useState<
  164. ReviewPanel.Value<'commentThreads'>
  165. >({})
  166. const [entries, setEntries] = useState<ReviewPanel.Value<'entries'>>({})
  167. const [users, setUsers] = useScopeValue<ReviewPanel.Value<'users'>>(
  168. 'users',
  169. true
  170. )
  171. const [resolvedComments, setResolvedComments] = useState<
  172. ReviewPanel.Value<'resolvedComments'>
  173. >({})
  174. const [shouldCollapse, setShouldCollapse] =
  175. useState<ReviewPanel.Value<'shouldCollapse'>>(true)
  176. const [lineHeight, setLineHeight] =
  177. useState<ReviewPanel.Value<'lineHeight'>>(0)
  178. const [formattedProjectMembers, setFormattedProjectMembers] = useState<
  179. ReviewPanel.Value<'formattedProjectMembers'>
  180. >({})
  181. const [trackChangesState, setTrackChangesState] = useState<
  182. ReviewPanel.Value<'trackChangesState'>
  183. >({})
  184. const [trackChangesOnForEveryone, setTrackChangesOnForEveryone] =
  185. useState<ReviewPanel.Value<'trackChangesOnForEveryone'>>(false)
  186. const [trackChangesOnForGuests, setTrackChangesOnForGuests] =
  187. useState<ReviewPanel.Value<'trackChangesOnForGuests'>>(false)
  188. const [trackChangesForGuestsAvailable, setTrackChangesForGuestsAvailable] =
  189. useState<ReviewPanel.Value<'trackChangesForGuestsAvailable'>>(false)
  190. const [resolvedThreadIds, setResolvedThreadIds] = useState<
  191. Record<ThreadId, boolean>
  192. >({})
  193. const [loadingThreads, setLoadingThreads] =
  194. useScopeValue<boolean>('loadingThreads')
  195. const loadThreadsController = useAbortController()
  196. const threadsLoadedOnceRef = useRef(false)
  197. const loadingThreadsInProgressRef = useRef(false)
  198. const ensureThreadsAreLoaded = useCallback(() => {
  199. if (threadsLoadedOnceRef.current) {
  200. // We get any updates in real time so only need to load them once.
  201. return
  202. }
  203. threadsLoadedOnceRef.current = true
  204. loadingThreadsInProgressRef.current = true
  205. return getJSON(`/project/${projectId}/threads`, {
  206. signal: loadThreadsController.signal,
  207. })
  208. .then(threads => {
  209. setLoadingThreads(false)
  210. const tempResolvedThreadIds: typeof resolvedThreadIds = {}
  211. const threadsEntries = Object.entries(threads) as [
  212. [
  213. ThreadId,
  214. MergeAndOverride<
  215. ReviewPanelCommentThread,
  216. ReviewPanelCommentThreadsApi[ThreadId]
  217. >,
  218. ],
  219. ]
  220. for (const [threadId, thread] of threadsEntries) {
  221. for (const comment of thread.messages) {
  222. formatComment(comment)
  223. }
  224. if (thread.resolved_by_user) {
  225. thread.resolved_by_user = formatUser(thread.resolved_by_user)
  226. tempResolvedThreadIds[threadId] = true
  227. }
  228. }
  229. setResolvedThreadIds(tempResolvedThreadIds)
  230. setCommentThreads(threads as unknown as ReviewPanelCommentThreads)
  231. dispatchReviewPanelEvent('loaded_threads')
  232. handleLayoutChange({ async: true })
  233. return {
  234. resolvedThreadIds: tempResolvedThreadIds,
  235. commentThreads: threads,
  236. }
  237. })
  238. .catch(debugConsole.error)
  239. .finally(() => {
  240. loadingThreadsInProgressRef.current = false
  241. })
  242. }, [loadThreadsController.signal, projectId, setLoadingThreads])
  243. const rangesTrackers = useRef<
  244. Record<DocId, RangesTrackerWithResolvedThreadIds>
  245. >({})
  246. const refreshingRangeUsers = useRef(false)
  247. const refreshedForUserIds = useRef(new Set<UserId>())
  248. const refreshChangeUsers = useCallback(
  249. (userId: UserId | null) => {
  250. if (userId != null) {
  251. if (refreshedForUserIds.current.has(userId)) {
  252. // We've already tried to refresh to get this user id, so stop it looping
  253. return
  254. }
  255. refreshedForUserIds.current.add(userId)
  256. }
  257. // Only do one refresh at once
  258. if (refreshingRangeUsers.current) {
  259. return
  260. }
  261. refreshingRangeUsers.current = true
  262. getJSON(`/project/${projectId}/changes/users`)
  263. .then(usersResponse => {
  264. refreshingRangeUsers.current = false
  265. const tempUsers = {} as ReviewPanel.Value<'users'>
  266. // Always include ourself, since if we submit an op, we might need to display info
  267. // about it locally before it has been flushed through the server
  268. if (user) {
  269. tempUsers[user.id] = formatUser(user)
  270. }
  271. for (const user of usersResponse) {
  272. if (user.id) {
  273. tempUsers[user.id] = formatUser(user)
  274. }
  275. }
  276. setUsers(tempUsers)
  277. })
  278. .catch(error => {
  279. refreshingRangeUsers.current = false
  280. debugConsole.error(error)
  281. })
  282. },
  283. [projectId, setUsers, user]
  284. )
  285. const getChangeTracker = useCallback(
  286. (docId: DocId) => {
  287. if (!rangesTrackers.current[docId]) {
  288. const rangesTracker = new RangesTracker([], [])
  289. ;(
  290. rangesTracker as RangesTrackerWithResolvedThreadIds
  291. ).resolvedThreadIds = { ...resolvedThreadIds }
  292. rangesTrackers.current[docId] =
  293. rangesTracker as RangesTrackerWithResolvedThreadIds
  294. }
  295. return rangesTrackers.current[docId]!
  296. },
  297. [resolvedThreadIds]
  298. )
  299. const getDocEntries = useCallback(
  300. (docId: DocId) => {
  301. return entries[docId] ?? ({} as ReviewPanelDocEntries)
  302. },
  303. [entries]
  304. )
  305. const getDocResolvedComments = useCallback(
  306. (docId: DocId) => {
  307. return resolvedComments[docId] ?? ({} as ReviewPanelDocEntries)
  308. },
  309. [resolvedComments]
  310. )
  311. const getThread = useCallback(
  312. (threadId: ThreadId) => {
  313. return (
  314. commentThreads[threadId] ??
  315. ({ messages: [] } as ReviewPanelCommentThread)
  316. )
  317. },
  318. [commentThreads]
  319. )
  320. const updateEntries = useCallback(
  321. async (docId: DocId) => {
  322. const rangesTracker = getChangeTracker(docId)
  323. const docEntries = cloneDeep(getDocEntries(docId))
  324. const docResolvedComments = cloneDeep(getDocResolvedComments(docId))
  325. // Assume we'll delete everything until we see it, then we'll remove it from this object
  326. const deleteChanges = new Set<keyof ReviewPanelDocEntries>()
  327. for (const [id, change] of Object.entries(docEntries) as Entries<
  328. typeof docEntries
  329. >) {
  330. if (
  331. 'entry_ids' in change &&
  332. id !== 'add-comment' &&
  333. id !== 'bulk-actions'
  334. ) {
  335. for (const entryId of change.entry_ids) {
  336. deleteChanges.add(entryId)
  337. }
  338. }
  339. }
  340. for (const [, change] of Object.entries(docResolvedComments) as Entries<
  341. typeof docResolvedComments
  342. >) {
  343. if ('entry_ids' in change) {
  344. for (const entryId of change.entry_ids) {
  345. deleteChanges.add(entryId)
  346. }
  347. }
  348. }
  349. let potentialAggregate = false
  350. let prevInsertion = null
  351. for (const change of rangesTracker.changes as any[]) {
  352. if (
  353. potentialAggregate &&
  354. change.op.d &&
  355. change.op.p === prevInsertion.op.p + prevInsertion.op.i.length &&
  356. change.metadata.user_id === prevInsertion.metadata.user_id
  357. ) {
  358. // An actual aggregate op.
  359. const aggregateChangeEntries = docEntries as Record<
  360. string,
  361. ReviewPanelAggregateChangeEntry
  362. >
  363. aggregateChangeEntries[prevInsertion.id].type = 'aggregate-change'
  364. aggregateChangeEntries[prevInsertion.id].metadata.replaced_content =
  365. change.op.d
  366. aggregateChangeEntries[prevInsertion.id].entry_ids.push(change.id)
  367. } else {
  368. if (docEntries[change.id] == null) {
  369. docEntries[change.id] = {} as ReviewPanelEntry
  370. }
  371. deleteChanges.delete(change.id)
  372. const newEntry: Partial<ReviewPanelChangeEntry> = {
  373. type: change.op.i ? 'insert' : 'delete',
  374. entry_ids: [change.id],
  375. content: change.op.i || change.op.d,
  376. offset: change.op.p,
  377. metadata: change.metadata,
  378. }
  379. for (const [key, value] of Object.entries(newEntry) as Entries<
  380. typeof newEntry
  381. >) {
  382. const entriesTyped = docEntries[change.id] as Record<any, any>
  383. entriesTyped[key] = value
  384. }
  385. }
  386. if (change.op.i) {
  387. potentialAggregate = true
  388. prevInsertion = change
  389. } else {
  390. potentialAggregate = false
  391. prevInsertion = null
  392. }
  393. if (!users[change.metadata.user_id]) {
  394. if (!isRestrictedTokenMember) {
  395. refreshChangeUsers(change.metadata.user_id)
  396. }
  397. }
  398. }
  399. let localResolvedThreadIds = resolvedThreadIds
  400. if (!isRestrictedTokenMember) {
  401. if (rangesTracker.comments.length > 0) {
  402. const threadsLoadResult = await ensureThreadsAreLoaded()
  403. if (threadsLoadResult?.resolvedThreadIds) {
  404. localResolvedThreadIds = threadsLoadResult.resolvedThreadIds
  405. }
  406. } else if (loadingThreads) {
  407. // ensure that tracked changes are highlighted even if no comments are loaded
  408. setLoadingThreads(false)
  409. dispatchReviewPanelEvent('loaded_threads')
  410. }
  411. }
  412. if (!loadingThreadsInProgressRef.current) {
  413. for (const comment of rangesTracker.comments) {
  414. const commentId = comment.id as ThreadId
  415. deleteChanges.delete(commentId)
  416. let newComment: any
  417. if (localResolvedThreadIds[comment.op.t]) {
  418. docResolvedComments[commentId] ??= {} as ReviewPanelCommentEntry
  419. newComment = docResolvedComments[commentId]
  420. delete docEntries[commentId]
  421. } else {
  422. docEntries[commentId] ??= {} as ReviewPanelEntry
  423. newComment = docEntries[commentId]
  424. delete docResolvedComments[commentId]
  425. }
  426. newComment.type = 'comment'
  427. newComment.thread_id = comment.op.t
  428. newComment.entry_ids = [comment.id]
  429. newComment.content = comment.op.c
  430. newComment.offset = comment.op.p
  431. }
  432. }
  433. deleteChanges.forEach(changeId => {
  434. delete docEntries[changeId]
  435. delete docResolvedComments[changeId]
  436. })
  437. setEntries(prev => {
  438. return isEqual(prev[docId], docEntries)
  439. ? prev
  440. : { ...prev, [docId]: docEntries }
  441. })
  442. setResolvedComments(prev => {
  443. return isEqual(prev[docId], docResolvedComments)
  444. ? prev
  445. : { ...prev, [docId]: docResolvedComments }
  446. })
  447. return docEntries
  448. },
  449. [
  450. getChangeTracker,
  451. getDocEntries,
  452. getDocResolvedComments,
  453. isRestrictedTokenMember,
  454. refreshChangeUsers,
  455. resolvedThreadIds,
  456. users,
  457. ensureThreadsAreLoaded,
  458. loadingThreads,
  459. setLoadingThreads,
  460. ]
  461. )
  462. const regenerateTrackChangesId = useCallback(
  463. (doc: typeof currentDocument) => {
  464. const currentChangeTracker = getChangeTracker(doc.doc_id as DocId)
  465. const oldId = currentChangeTracker.getIdSeed()
  466. const newId = RangesTracker.generateIdSeed()
  467. currentChangeTracker.setIdSeed(newId)
  468. doc.setTrackChangesIdSeeds({ pending: newId, inflight: oldId })
  469. },
  470. [getChangeTracker]
  471. )
  472. useEffect(() => {
  473. if (!currentDocument) {
  474. return
  475. }
  476. // The open doc range tracker is kept up to date in real-time so
  477. // replace any outdated info with this
  478. const rangesTracker = currentDocument.ranges!
  479. ;(rangesTracker as RangesTrackerWithResolvedThreadIds).resolvedThreadIds = {
  480. ...resolvedThreadIds,
  481. }
  482. rangesTrackers.current[currentDocument.doc_id as DocId] =
  483. rangesTracker as RangesTrackerWithResolvedThreadIds
  484. currentDocument.on('flipped_pending_to_inflight', () =>
  485. regenerateTrackChangesId(currentDocument)
  486. )
  487. regenerateTrackChangesId(currentDocument)
  488. return () => {
  489. currentDocument.off('flipped_pending_to_inflight')
  490. }
  491. }, [currentDocument, regenerateTrackChangesId, resolvedThreadIds])
  492. const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous' => {
  493. if (!user) {
  494. return 'anonymous'
  495. }
  496. // FIXME: check this
  497. if (project.owner._id === user.id) {
  498. return 'member'
  499. }
  500. for (const member of project.members as any[]) {
  501. if (member._id === user.id) {
  502. return 'member'
  503. }
  504. }
  505. return 'guest'
  506. }, [project.members, project.owner, user])
  507. const applyClientTrackChangesStateToServer = useCallback(
  508. (
  509. trackChangesOnForEveryone: boolean,
  510. trackChangesOnForGuests: boolean,
  511. trackChangesState: ReviewPanel.Value<'trackChangesState'>
  512. ) => {
  513. const data: {
  514. on?: boolean
  515. on_for?: Record<UserId, boolean>
  516. on_for_guests?: boolean
  517. } = {}
  518. if (trackChangesOnForEveryone) {
  519. data.on = true
  520. } else {
  521. data.on_for = {}
  522. const entries = Object.entries(trackChangesState) as Array<
  523. [
  524. UserId,
  525. NonNullable<
  526. (typeof trackChangesState)[keyof typeof trackChangesState]
  527. >,
  528. ]
  529. >
  530. for (const [userId, { value }] of entries) {
  531. data.on_for[userId] = value
  532. }
  533. if (trackChangesOnForGuests) {
  534. data.on_for_guests = true
  535. }
  536. }
  537. postJSON(`/project/${projectId}/track_changes`, {
  538. body: data,
  539. }).catch(debugConsole.error)
  540. },
  541. [projectId]
  542. )
  543. const setGuestsTCState = useCallback(
  544. (newValue: boolean) => {
  545. setTrackChangesOnForGuests(newValue)
  546. if (currentUserType() === 'guest' || currentUserType() === 'anonymous') {
  547. setWantTrackChanges(newValue)
  548. }
  549. },
  550. [currentUserType, setWantTrackChanges]
  551. )
  552. const setUserTCState = useCallback(
  553. (
  554. trackChangesState: DeepReadonly<ReviewPanel.Value<'trackChangesState'>>,
  555. userId: UserId,
  556. newValue: boolean,
  557. isLocal = false
  558. ) => {
  559. const newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  560. ...trackChangesState,
  561. }
  562. const state =
  563. newTrackChangesState[userId] ??
  564. ({} as NonNullable<(typeof newTrackChangesState)[UserId]>)
  565. newTrackChangesState[userId] = state
  566. if (state.syncState == null || state.syncState === 'synced') {
  567. state.value = newValue
  568. state.syncState = 'synced'
  569. } else if (state.syncState === 'pending' && state.value === newValue) {
  570. state.syncState = 'synced'
  571. } else if (isLocal) {
  572. state.value = newValue
  573. state.syncState = 'pending'
  574. }
  575. setTrackChangesState(newTrackChangesState)
  576. if (userId === user.id) {
  577. setWantTrackChanges(newValue)
  578. }
  579. return newTrackChangesState
  580. },
  581. [setWantTrackChanges, user.id]
  582. )
  583. const setEveryoneTCState = useCallback(
  584. (newValue: boolean, isLocal = false) => {
  585. setTrackChangesOnForEveryone(newValue)
  586. let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  587. ...trackChangesState,
  588. }
  589. for (const member of project.members as any[]) {
  590. newTrackChangesState = setUserTCState(
  591. newTrackChangesState,
  592. member._id,
  593. newValue,
  594. isLocal
  595. )
  596. }
  597. setGuestsTCState(newValue)
  598. newTrackChangesState = setUserTCState(
  599. newTrackChangesState,
  600. project.owner._id,
  601. newValue,
  602. isLocal
  603. )
  604. return { trackChangesState: newTrackChangesState }
  605. },
  606. [
  607. project.members,
  608. project.owner._id,
  609. setGuestsTCState,
  610. setUserTCState,
  611. trackChangesState,
  612. ]
  613. )
  614. const toggleTrackChangesForEveryone = useCallback<
  615. ReviewPanel.UpdaterFn<'toggleTrackChangesForEveryone'>
  616. >(
  617. (onForEveryone: boolean) => {
  618. const { trackChangesState } = setEveryoneTCState(onForEveryone, true)
  619. setGuestsTCState(onForEveryone)
  620. applyClientTrackChangesStateToServer(
  621. onForEveryone,
  622. onForEveryone,
  623. trackChangesState
  624. )
  625. },
  626. [applyClientTrackChangesStateToServer, setEveryoneTCState, setGuestsTCState]
  627. )
  628. const toggleTrackChangesForGuests = useCallback<
  629. ReviewPanel.UpdaterFn<'toggleTrackChangesForGuests'>
  630. >(
  631. (onForGuests: boolean) => {
  632. setGuestsTCState(onForGuests)
  633. applyClientTrackChangesStateToServer(
  634. trackChangesOnForEveryone,
  635. onForGuests,
  636. trackChangesState
  637. )
  638. },
  639. [
  640. applyClientTrackChangesStateToServer,
  641. setGuestsTCState,
  642. trackChangesOnForEveryone,
  643. trackChangesState,
  644. ]
  645. )
  646. const toggleTrackChangesForUser = useCallback<
  647. ReviewPanel.UpdaterFn<'toggleTrackChangesForUser'>
  648. >(
  649. (onForUser: boolean, userId: UserId) => {
  650. const newTrackChangesState = setUserTCState(
  651. trackChangesState,
  652. userId,
  653. onForUser,
  654. true
  655. )
  656. applyClientTrackChangesStateToServer(
  657. trackChangesOnForEveryone,
  658. trackChangesOnForGuests,
  659. newTrackChangesState
  660. )
  661. },
  662. [
  663. applyClientTrackChangesStateToServer,
  664. setUserTCState,
  665. trackChangesOnForEveryone,
  666. trackChangesOnForGuests,
  667. trackChangesState,
  668. ]
  669. )
  670. const applyTrackChangesStateToClient = useCallback(
  671. (state: boolean | ReviewPanel.Value<'trackChangesState'>) => {
  672. if (typeof state === 'boolean') {
  673. setEveryoneTCState(state)
  674. setGuestsTCState(state)
  675. } else {
  676. setTrackChangesOnForEveryone(false)
  677. // TODO
  678. // @ts-ignore
  679. setGuestsTCState(state.__guests__ === true)
  680. let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  681. ...trackChangesState,
  682. }
  683. for (const member of project.members as any[]) {
  684. newTrackChangesState = setUserTCState(
  685. newTrackChangesState,
  686. member._id,
  687. !!state[member._id]
  688. )
  689. }
  690. newTrackChangesState = setUserTCState(
  691. newTrackChangesState,
  692. project.owner._id,
  693. !!state[project.owner._id]
  694. )
  695. return newTrackChangesState
  696. }
  697. },
  698. [
  699. project.members,
  700. project.owner._id,
  701. setEveryoneTCState,
  702. setGuestsTCState,
  703. setUserTCState,
  704. trackChangesState,
  705. ]
  706. )
  707. const setGuestFeatureBasedOnProjectAccessLevel = (
  708. projectPublicAccessLevel?: PublicAccessLevel
  709. ) => {
  710. setTrackChangesForGuestsAvailable(projectPublicAccessLevel === 'tokenBased')
  711. }
  712. useEffect(() => {
  713. setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
  714. }, [project.publicAccessLevel])
  715. useEffect(() => {
  716. if (
  717. trackChangesForGuestsAvailable ||
  718. !trackChangesOnForGuests ||
  719. trackChangesOnForEveryone
  720. ) {
  721. return
  722. }
  723. // Overrides guest setting
  724. toggleTrackChangesForGuests(false)
  725. }, [
  726. toggleTrackChangesForGuests,
  727. trackChangesForGuestsAvailable,
  728. trackChangesOnForEveryone,
  729. trackChangesOnForGuests,
  730. ])
  731. const projectJoinedEffectExecuted = useRef(false)
  732. useEffect(() => {
  733. if (!projectJoinedEffectExecuted.current) {
  734. projectJoinedEffectExecuted.current = true
  735. requestAnimationFrame(() => {
  736. if (trackChanges) {
  737. applyTrackChangesStateToClient(project.trackChangesState)
  738. } else {
  739. applyTrackChangesStateToClient(false)
  740. }
  741. setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
  742. })
  743. }
  744. }, [
  745. applyTrackChangesStateToClient,
  746. trackChanges,
  747. project.publicAccessLevel,
  748. project.trackChangesState,
  749. ])
  750. useEffect(() => {
  751. setFormattedProjectMembers(prevState => {
  752. const tempFormattedProjectMembers: typeof prevState = {}
  753. if (project.owner) {
  754. tempFormattedProjectMembers[project.owner._id] = formatUser(
  755. project.owner
  756. )
  757. }
  758. const members = project.members ?? []
  759. for (const member of members) {
  760. if (member.privileges === 'readAndWrite') {
  761. if (!trackChangesState[member._id]) {
  762. // An added member will have track changes enabled if track changes is on for everyone
  763. setUserTCState(
  764. trackChangesState,
  765. member._id,
  766. trackChangesOnForEveryone,
  767. true
  768. )
  769. }
  770. tempFormattedProjectMembers[member._id] = formatUser(member)
  771. }
  772. }
  773. return tempFormattedProjectMembers
  774. })
  775. }, [
  776. project.members,
  777. project.owner,
  778. setUserTCState,
  779. trackChangesOnForEveryone,
  780. trackChangesState,
  781. ])
  782. useSocketListener(
  783. socket,
  784. 'toggle-track-changes',
  785. applyTrackChangesStateToClient
  786. )
  787. const gotoEntry = useCallback(
  788. (docId: DocId, entryOffset: number) => {
  789. openDocId(docId, { gotoOffset: entryOffset })
  790. },
  791. [openDocId]
  792. )
  793. const view = reviewPanelOpen ? subView : 'mini'
  794. const toggleReviewPanel = useCallback(() => {
  795. if (!trackChangesVisible) {
  796. return
  797. }
  798. setReviewPanelOpen(!reviewPanelOpen)
  799. sendMB('rp-toggle-panel', {
  800. value: !reviewPanelOpen,
  801. })
  802. }, [reviewPanelOpen, setReviewPanelOpen, trackChangesVisible])
  803. const onCommentResolved = useCallback(
  804. (threadId: ThreadId, user: any) => {
  805. setCommentThreads(prevState => {
  806. const thread = { ...getThread(threadId) }
  807. thread.resolved = true
  808. thread.resolved_by_user = formatUser(user)
  809. thread.resolved_at = new Date().toISOString() as DateString
  810. return { ...prevState, [threadId]: thread }
  811. })
  812. setResolvedThreadIds(prevState => ({ ...prevState, [threadId]: true }))
  813. dispatchReviewPanelEvent('comment:resolve_threads', [threadId])
  814. },
  815. [getThread]
  816. )
  817. const resolveComment = useCallback(
  818. (docId: DocId, entryId: ThreadId) => {
  819. const docEntries = getDocEntries(docId)
  820. const entry = docEntries[entryId] as ReviewPanelCommentEntry
  821. setEntries(prevState => ({
  822. ...prevState,
  823. [docId]: {
  824. ...prevState[docId],
  825. [entryId]: {
  826. ...prevState[docId][entryId],
  827. focused: false,
  828. },
  829. },
  830. }))
  831. postJSON(`/project/${projectId}/thread/${entry.thread_id}/resolve`)
  832. onCommentResolved(entry.thread_id, user)
  833. sendMB('rp-comment-resolve', { view })
  834. },
  835. [getDocEntries, onCommentResolved, projectId, user, view]
  836. )
  837. const onCommentReopened = useCallback(
  838. (threadId: ThreadId) => {
  839. setCommentThreads(prevState => {
  840. const {
  841. resolved: _1,
  842. resolved_by_user: _2,
  843. resolved_at: _3,
  844. ...thread
  845. } = getThread(threadId)
  846. return { ...prevState, [threadId]: thread }
  847. })
  848. setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => {
  849. return resolvedThreadIds
  850. })
  851. dispatchReviewPanelEvent('comment:unresolve_thread', threadId)
  852. },
  853. [getThread]
  854. )
  855. const unresolveComment = useCallback(
  856. (threadId: ThreadId) => {
  857. onCommentReopened(threadId)
  858. const url = `/project/${projectId}/thread/${threadId}/reopen`
  859. postJSON(url).catch(debugConsole.error)
  860. sendMB('rp-comment-reopen')
  861. },
  862. [onCommentReopened, projectId]
  863. )
  864. const onThreadDeleted = useCallback((threadId: ThreadId) => {
  865. setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => {
  866. return resolvedThreadIds
  867. })
  868. setCommentThreads(({ [threadId]: _, ...commentThreads }) => {
  869. return commentThreads
  870. })
  871. dispatchReviewPanelEvent('comment:remove', threadId)
  872. }, [])
  873. const deleteThread = useCallback(
  874. (docId: DocId, threadId: ThreadId) => {
  875. onThreadDeleted(threadId)
  876. deleteJSON(`/project/${projectId}/doc/${docId}/thread/${threadId}`).catch(
  877. debugConsole.error
  878. )
  879. sendMB('rp-comment-delete')
  880. },
  881. [onThreadDeleted, projectId]
  882. )
  883. const onCommentEdited = useCallback(
  884. (threadId: ThreadId, commentId: CommentId, content: string) => {
  885. setCommentThreads(prevState => {
  886. const thread = { ...getThread(threadId) }
  887. thread.messages = thread.messages.map(message => {
  888. return message.id === commentId ? { ...message, content } : message
  889. })
  890. return { ...prevState, [threadId]: thread }
  891. })
  892. },
  893. [getThread]
  894. )
  895. const saveEdit = useCallback(
  896. (threadId: ThreadId, commentId: CommentId, content: string) => {
  897. const url = `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`
  898. postJSON(url, { body: { content } }).catch(debugConsole.error)
  899. handleLayoutChange({ async: true })
  900. },
  901. [projectId]
  902. )
  903. const onCommentDeleted = useCallback(
  904. (threadId: ThreadId, commentId: CommentId) => {
  905. setCommentThreads(prevState => {
  906. const thread = { ...getThread(threadId) }
  907. thread.messages = thread.messages.filter(m => m.id !== commentId)
  908. return { ...prevState, [threadId]: thread }
  909. })
  910. },
  911. [getThread]
  912. )
  913. const deleteComment = useCallback(
  914. (threadId: ThreadId, commentId: CommentId) => {
  915. onCommentDeleted(threadId, commentId)
  916. deleteJSON(
  917. `/project/${projectId}/thread/${threadId}/messages/${commentId}`
  918. ).catch(debugConsole.error)
  919. handleLayoutChange({ async: true })
  920. },
  921. [onCommentDeleted, projectId]
  922. )
  923. const doAcceptChanges = useCallback(
  924. (entryIds: ThreadId[]) => {
  925. const url = `/project/${projectId}/doc/${currentDocumentId}/changes/accept`
  926. postJSON(url, { body: { change_ids: entryIds } }).catch(
  927. debugConsole.error
  928. )
  929. dispatchReviewPanelEvent('changes:accept', entryIds)
  930. },
  931. [currentDocumentId, projectId]
  932. )
  933. const acceptChanges = useCallback(
  934. (entryIds: ThreadId[]) => {
  935. doAcceptChanges(entryIds)
  936. sendMB('rp-changes-accepted', { view })
  937. },
  938. [doAcceptChanges, view]
  939. )
  940. const doRejectChanges = useCallback((entryIds: ThreadId[]) => {
  941. dispatchReviewPanelEvent('changes:reject', entryIds)
  942. }, [])
  943. const rejectChanges = useCallback(
  944. (entryIds: ThreadId[]) => {
  945. doRejectChanges(entryIds)
  946. sendMB('rp-changes-rejected', { view })
  947. },
  948. [doRejectChanges, view]
  949. )
  950. const bulkAcceptActions = useCallback(() => {
  951. doAcceptChanges(selectedEntryIds.current)
  952. sendMB('rp-bulk-accept', { view, nEntries: nVisibleSelectedChanges })
  953. }, [doAcceptChanges, nVisibleSelectedChanges, view])
  954. const bulkRejectActions = useCallback(() => {
  955. doRejectChanges(selectedEntryIds.current)
  956. sendMB('rp-bulk-reject', { view, nEntries: nVisibleSelectedChanges })
  957. }, [doRejectChanges, nVisibleSelectedChanges, view])
  958. const refreshRanges = useCallback(() => {
  959. type Doc = {
  960. id: DocId
  961. ranges: {
  962. comments?: Change<CommentOperation>[]
  963. changes?: Change<EditOperation>[]
  964. }
  965. }
  966. return getJSON<Doc[]>(`/project/${projectId}/ranges`)
  967. .then(docs => {
  968. setCollapsed(prevState => {
  969. const collapsed = { ...prevState }
  970. docs.forEach(doc => {
  971. if (collapsed[doc.id] == null) {
  972. collapsed[doc.id] = false
  973. }
  974. })
  975. return collapsed
  976. })
  977. docs.forEach(async doc => {
  978. if (doc.id !== currentDocumentId) {
  979. // this is kept up to date in real-time, don't overwrite
  980. const rangesTracker = getChangeTracker(doc.id)
  981. rangesTracker.comments = doc.ranges?.comments ?? []
  982. rangesTracker.changes = doc.ranges?.changes ?? []
  983. }
  984. })
  985. return Promise.all(docs.map(doc => updateEntries(doc.id)))
  986. })
  987. .catch(debugConsole.error)
  988. }, [
  989. currentDocumentId,
  990. getChangeTracker,
  991. projectId,
  992. setCollapsed,
  993. updateEntries,
  994. ])
  995. const handleSetSubview = useCallback((subView: SubView) => {
  996. setSubView(subView)
  997. sendMB('rp-subview-change', { subView })
  998. }, [])
  999. const submitReply = useCallback(
  1000. (threadId: ThreadId, replyContent: string) => {
  1001. const url = `/project/${projectId}/thread/${threadId}/messages`
  1002. postJSON(url, { body: { content: replyContent } }).catch(() => {
  1003. showGenericMessageModal(
  1004. t('error_submitting_comment'),
  1005. t('comment_submit_error')
  1006. )
  1007. })
  1008. const trackingMetadata = {
  1009. view,
  1010. size: replyContent.length,
  1011. thread: threadId,
  1012. }
  1013. setCommentThreads(prevState => ({
  1014. ...prevState,
  1015. [threadId]: { ...getThread(threadId), submitting: true },
  1016. }))
  1017. handleLayoutChange({ async: true })
  1018. sendMB('rp-comment-reply', trackingMetadata)
  1019. },
  1020. [getThread, projectId, showGenericMessageModal, t, view]
  1021. )
  1022. // TODO `submitNewComment` is partially localized in the `add-comment-entry` component.
  1023. const submitNewComment = useCallback(
  1024. (content: string) => {
  1025. if (!content) {
  1026. return
  1027. }
  1028. const entries = getDocEntries(currentDocumentId)
  1029. const addCommentEntry = entries['add-comment'] as
  1030. | ReviewPanelAddCommentEntry
  1031. | undefined
  1032. if (!addCommentEntry) {
  1033. return
  1034. }
  1035. const { offset, length } = addCommentEntry
  1036. const threadId = RangesTracker.generateId() as ThreadId
  1037. setCommentThreads(prevState => ({
  1038. ...prevState,
  1039. [threadId]: { ...getThread(threadId), submitting: true },
  1040. }))
  1041. const url = `/project/${projectId}/thread/${threadId}/messages`
  1042. postJSON(url, { body: { content } })
  1043. .then(() => {
  1044. dispatchReviewPanelEvent('comment:add', { threadId, offset, length })
  1045. handleLayoutChange({ async: true })
  1046. sendMB('rp-new-comment', { size: content.length })
  1047. })
  1048. .catch(() => {
  1049. showGenericMessageModal(
  1050. t('error_submitting_comment'),
  1051. t('comment_submit_error')
  1052. )
  1053. })
  1054. },
  1055. [
  1056. currentDocumentId,
  1057. getDocEntries,
  1058. getThread,
  1059. projectId,
  1060. showGenericMessageModal,
  1061. t,
  1062. ]
  1063. )
  1064. const [isAddingComment, setIsAddingComment] = useState(false)
  1065. const [navHeight, setNavHeight] = useState(0)
  1066. const [toolbarHeight, setToolbarHeight] = useState(0)
  1067. const [layoutSuspended, setLayoutSuspended] = useState(false)
  1068. const [unsavedComment, setUnsavedComment] = useState('')
  1069. useEffect(() => {
  1070. if (!trackChangesVisible) {
  1071. setReviewPanelOpen(false)
  1072. }
  1073. }, [trackChangesVisible, setReviewPanelOpen])
  1074. const hasEntries = useMemo(() => {
  1075. const docEntries = getDocEntries(currentDocumentId)
  1076. const permEntriesCount = Object.keys(docEntries).filter(key => {
  1077. return !['add-comment', 'bulk-actions'].includes(key)
  1078. }).length
  1079. return permEntriesCount > 0 && trackChangesVisible
  1080. }, [currentDocumentId, getDocEntries, trackChangesVisible])
  1081. useEffect(() => {
  1082. setMiniReviewPanelVisible(!reviewPanelOpen && !!hasEntries)
  1083. }, [reviewPanelOpen, hasEntries, setMiniReviewPanelVisible])
  1084. // listen for events from the CodeMirror 6 track changes extension
  1085. useEffect(() => {
  1086. const toggleTrackChangesFromKbdShortcut = () => {
  1087. if (trackChangesVisible && trackChanges) {
  1088. const userId: UserId = user.id
  1089. const state = trackChangesState[userId]
  1090. if (state) {
  1091. toggleTrackChangesForUser(!state.value, userId)
  1092. }
  1093. }
  1094. }
  1095. const editorLineHeightChanged = (payload: typeof lineHeight) => {
  1096. setLineHeight(payload)
  1097. handleLayoutChange()
  1098. }
  1099. const editorTrackChangesChanged = async () => {
  1100. const tempEntries = cloneDeep(await updateEntries(currentDocumentId))
  1101. // `tempEntries` would be mutated
  1102. dispatchReviewPanelEvent('recalculate-screen-positions', {
  1103. entries: tempEntries,
  1104. updateType: 'trackedChangesChange',
  1105. })
  1106. // The state should be updated after dispatching the 'recalculate-screen-positions'
  1107. // event as `tempEntries` will be mutated
  1108. setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries }))
  1109. handleLayoutChange()
  1110. }
  1111. const editorTrackChangesVisibilityChanged = () => {
  1112. handleLayoutChange({ async: true, animate: false })
  1113. }
  1114. const editorFocusChanged = (
  1115. selectionOffsetStart: number,
  1116. selectionOffsetEnd: number,
  1117. selection: boolean,
  1118. updateType: UpdateType
  1119. ) => {
  1120. let tempEntries = cloneDeep(getDocEntries(currentDocumentId))
  1121. // All selected changes will be added to this array.
  1122. selectedEntryIds.current = []
  1123. // Count of user-visible changes, i.e. an aggregated change will count as one.
  1124. let tempNVisibleSelectedChanges = 0
  1125. const offset = selectionOffsetStart
  1126. const length = selectionOffsetEnd - selectionOffsetStart
  1127. // Recreate the add comment and bulk actions entries only when
  1128. // necessary. This is to avoid the UI thinking that these entries have
  1129. // changed and getting into an infinite loop.
  1130. if (selection) {
  1131. const existingAddComment = tempEntries[
  1132. 'add-comment'
  1133. ] as ReviewPanelAddCommentEntry
  1134. if (
  1135. !existingAddComment ||
  1136. existingAddComment.offset !== offset ||
  1137. existingAddComment.length !== length
  1138. ) {
  1139. tempEntries['add-comment'] = {
  1140. type: 'add-comment',
  1141. offset,
  1142. length,
  1143. } as ReviewPanelAddCommentEntry
  1144. }
  1145. const existingBulkActions = tempEntries[
  1146. 'bulk-actions'
  1147. ] as ReviewPanelBulkActionsEntry
  1148. if (
  1149. !existingBulkActions ||
  1150. existingBulkActions.offset !== offset ||
  1151. existingBulkActions.length !== length
  1152. ) {
  1153. tempEntries['bulk-actions'] = {
  1154. type: 'bulk-actions',
  1155. offset,
  1156. length,
  1157. } as ReviewPanelBulkActionsEntry
  1158. }
  1159. } else {
  1160. delete (tempEntries as Partial<typeof tempEntries>)['add-comment']
  1161. delete (tempEntries as Partial<typeof tempEntries>)['bulk-actions']
  1162. }
  1163. for (const [key, entry] of Object.entries(tempEntries) as Entries<
  1164. typeof tempEntries
  1165. >) {
  1166. let isChangeEntryAndWithinSelection = false
  1167. if (entry.type === 'comment' && !resolvedThreadIds[entry.thread_id]) {
  1168. tempEntries = {
  1169. ...tempEntries,
  1170. [key]: {
  1171. ...tempEntries[key],
  1172. focused:
  1173. entry.offset <= selectionOffsetStart &&
  1174. selectionOffsetStart <= entry.offset + entry.content.length,
  1175. },
  1176. }
  1177. } else if (
  1178. entry.type === 'insert' ||
  1179. entry.type === 'aggregate-change'
  1180. ) {
  1181. isChangeEntryAndWithinSelection =
  1182. entry.offset >= selectionOffsetStart &&
  1183. entry.offset + entry.content.length <= selectionOffsetEnd
  1184. tempEntries = {
  1185. ...tempEntries,
  1186. [key]: {
  1187. ...tempEntries[key],
  1188. focused:
  1189. entry.offset <= selectionOffsetStart &&
  1190. selectionOffsetStart <= entry.offset + entry.content.length,
  1191. },
  1192. }
  1193. } else if (entry.type === 'delete') {
  1194. isChangeEntryAndWithinSelection =
  1195. selectionOffsetStart <= entry.offset &&
  1196. entry.offset <= selectionOffsetEnd
  1197. tempEntries = {
  1198. ...tempEntries,
  1199. [key]: {
  1200. ...tempEntries[key],
  1201. focused: entry.offset === selectionOffsetStart,
  1202. },
  1203. }
  1204. } else if (
  1205. ['add-comment', 'bulk-actions'].includes(entry.type) &&
  1206. selection
  1207. ) {
  1208. tempEntries = {
  1209. ...tempEntries,
  1210. [key]: { ...tempEntries[key], focused: true },
  1211. }
  1212. }
  1213. if (isChangeEntryAndWithinSelection) {
  1214. const entryIds = 'entry_ids' in entry ? entry.entry_ids : []
  1215. for (const entryId of entryIds) {
  1216. selectedEntryIds.current.push(entryId)
  1217. }
  1218. tempNVisibleSelectedChanges++
  1219. }
  1220. }
  1221. // `tempEntries` would be mutated
  1222. dispatchReviewPanelEvent('recalculate-screen-positions', {
  1223. entries: tempEntries,
  1224. updateType,
  1225. })
  1226. // The state should be updated after dispatching the 'recalculate-screen-positions'
  1227. // event as `tempEntries` will be mutated
  1228. setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries }))
  1229. setNVisibleSelectedChanges(tempNVisibleSelectedChanges)
  1230. handleLayoutChange()
  1231. }
  1232. const addNewCommentFromKbdShortcut = () => {
  1233. if (!trackChangesVisible) {
  1234. return
  1235. }
  1236. dispatchReviewPanelEvent('comment:select_line')
  1237. if (!reviewPanelOpen) {
  1238. toggleReviewPanel()
  1239. }
  1240. handleLayoutChange({ async: true })
  1241. addCommentEmitter()
  1242. }
  1243. const handleEditorEvents = (e: Event) => {
  1244. const event = e as CustomEvent
  1245. const { type, payload } = event.detail
  1246. switch (type) {
  1247. case 'line-height': {
  1248. editorLineHeightChanged(payload)
  1249. break
  1250. }
  1251. case 'track-changes:changed': {
  1252. editorTrackChangesChanged()
  1253. break
  1254. }
  1255. case 'track-changes:visibility_changed': {
  1256. editorTrackChangesVisibilityChanged()
  1257. break
  1258. }
  1259. case 'focus:changed': {
  1260. const { from, to, empty, updateType } = payload
  1261. editorFocusChanged(from, to, !empty, updateType)
  1262. break
  1263. }
  1264. case 'add-new-comment': {
  1265. addNewCommentFromKbdShortcut()
  1266. break
  1267. }
  1268. case 'toggle-track-changes': {
  1269. toggleTrackChangesFromKbdShortcut()
  1270. break
  1271. }
  1272. case 'toggle-review-panel': {
  1273. toggleReviewPanel()
  1274. break
  1275. }
  1276. }
  1277. }
  1278. window.addEventListener('editor:event', handleEditorEvents)
  1279. return () => {
  1280. window.removeEventListener('editor:event', handleEditorEvents)
  1281. }
  1282. }, [
  1283. addCommentEmitter,
  1284. currentDocumentId,
  1285. getDocEntries,
  1286. resolvedThreadIds,
  1287. reviewPanelOpen,
  1288. toggleReviewPanel,
  1289. toggleTrackChangesForUser,
  1290. trackChanges,
  1291. trackChangesState,
  1292. trackChangesVisible,
  1293. updateEntries,
  1294. user.id,
  1295. ])
  1296. useSocketListener(socket, 'reopen-thread', onCommentReopened)
  1297. useSocketListener(socket, 'delete-thread', onThreadDeleted)
  1298. useSocketListener(socket, 'resolve-thread', onCommentResolved)
  1299. useSocketListener(socket, 'edit-message', onCommentEdited)
  1300. useSocketListener(socket, 'delete-message', onCommentDeleted)
  1301. useSocketListener(
  1302. socket,
  1303. 'accept-changes',
  1304. useCallback(
  1305. (docId: DocId, entryIds: ThreadId[]) => {
  1306. if (docId !== currentDocumentId) {
  1307. getChangeTracker(docId).removeChangeIds(entryIds)
  1308. } else {
  1309. dispatchReviewPanelEvent('changes:accept', entryIds)
  1310. }
  1311. updateEntries(docId)
  1312. },
  1313. [currentDocumentId, getChangeTracker, updateEntries]
  1314. )
  1315. )
  1316. useSocketListener(
  1317. socket,
  1318. 'new-comment',
  1319. useCallback(
  1320. (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => {
  1321. setCommentThreads(prevState => {
  1322. const { submitting: _, ...thread } = getThread(threadId)
  1323. thread.messages = [...thread.messages]
  1324. thread.messages.push(formatComment(comment))
  1325. return { ...prevState, [threadId]: thread }
  1326. })
  1327. handleLayoutChange({ async: true })
  1328. },
  1329. [getThread]
  1330. )
  1331. )
  1332. const openSubView = useRef<typeof subView>('cur_file')
  1333. useEffect(() => {
  1334. if (!reviewPanelOpen) {
  1335. // Always show current file when not open, but save current state
  1336. setSubView(prevState => {
  1337. openSubView.current = prevState
  1338. return 'cur_file'
  1339. })
  1340. } else {
  1341. // Reset back to what we had when previously open
  1342. setSubView(openSubView.current)
  1343. }
  1344. handleLayoutChange({ async: true, animate: false })
  1345. }, [reviewPanelOpen])
  1346. const canRefreshRanges = useRef(false)
  1347. useEffect(() => {
  1348. if (subView === 'overview' && canRefreshRanges.current) {
  1349. canRefreshRanges.current = false
  1350. setIsOverviewLoading(true)
  1351. refreshRanges().finally(() => {
  1352. setIsOverviewLoading(false)
  1353. })
  1354. }
  1355. }, [subView, refreshRanges])
  1356. const prevSubView = useRef(subView)
  1357. const initializedPrevSubView = useRef(false)
  1358. useEffect(() => {
  1359. // Prevent setting a computed value for `prevSubView` on mount
  1360. if (!initializedPrevSubView.current) {
  1361. initializedPrevSubView.current = true
  1362. return
  1363. }
  1364. prevSubView.current = subView === 'cur_file' ? 'overview' : 'cur_file'
  1365. // Allow refreshing ranges once for each `subView` change
  1366. canRefreshRanges.current = true
  1367. }, [subView])
  1368. useEffect(() => {
  1369. if (subView === 'cur_file' && prevSubView.current === 'overview') {
  1370. dispatchReviewPanelEvent('overview-closed', subView)
  1371. }
  1372. }, [subView])
  1373. useEffect(() => {
  1374. if (Object.keys(users).length) {
  1375. handleLayoutChange({ async: true })
  1376. }
  1377. }, [users])
  1378. const values = useMemo<ReviewPanel.ReviewPanelState['values']>(
  1379. () => ({
  1380. collapsed,
  1381. commentThreads,
  1382. entries,
  1383. isAddingComment,
  1384. loadingThreads,
  1385. nVisibleSelectedChanges,
  1386. permissions,
  1387. users,
  1388. resolvedComments,
  1389. shouldCollapse,
  1390. navHeight,
  1391. toolbarHeight,
  1392. subView,
  1393. wantTrackChanges,
  1394. isOverviewLoading,
  1395. openDocId: currentDocumentId,
  1396. lineHeight,
  1397. trackChangesState,
  1398. trackChangesOnForEveryone,
  1399. trackChangesOnForGuests,
  1400. trackChangesForGuestsAvailable,
  1401. formattedProjectMembers,
  1402. layoutSuspended,
  1403. unsavedComment,
  1404. layoutToLeft,
  1405. }),
  1406. [
  1407. collapsed,
  1408. commentThreads,
  1409. entries,
  1410. isAddingComment,
  1411. loadingThreads,
  1412. nVisibleSelectedChanges,
  1413. permissions,
  1414. users,
  1415. resolvedComments,
  1416. shouldCollapse,
  1417. navHeight,
  1418. toolbarHeight,
  1419. subView,
  1420. wantTrackChanges,
  1421. isOverviewLoading,
  1422. currentDocumentId,
  1423. lineHeight,
  1424. trackChangesState,
  1425. trackChangesOnForEveryone,
  1426. trackChangesOnForGuests,
  1427. trackChangesForGuestsAvailable,
  1428. formattedProjectMembers,
  1429. layoutSuspended,
  1430. unsavedComment,
  1431. layoutToLeft,
  1432. ]
  1433. )
  1434. const updaterFns = useMemo<ReviewPanel.ReviewPanelState['updaterFns']>(
  1435. () => ({
  1436. handleSetSubview,
  1437. handleLayoutChange,
  1438. gotoEntry,
  1439. resolveComment,
  1440. submitReply,
  1441. acceptChanges,
  1442. rejectChanges,
  1443. toggleReviewPanel,
  1444. bulkAcceptActions,
  1445. bulkRejectActions,
  1446. saveEdit,
  1447. submitNewComment,
  1448. deleteComment,
  1449. unresolveComment,
  1450. refreshResolvedCommentsDropdown: refreshRanges,
  1451. deleteThread,
  1452. toggleTrackChangesForEveryone,
  1453. toggleTrackChangesForUser,
  1454. toggleTrackChangesForGuests,
  1455. setCollapsed,
  1456. setShouldCollapse,
  1457. setIsAddingComment,
  1458. setNavHeight,
  1459. setToolbarHeight,
  1460. setLayoutSuspended,
  1461. setUnsavedComment,
  1462. }),
  1463. [
  1464. handleSetSubview,
  1465. gotoEntry,
  1466. resolveComment,
  1467. submitReply,
  1468. acceptChanges,
  1469. rejectChanges,
  1470. toggleReviewPanel,
  1471. bulkAcceptActions,
  1472. bulkRejectActions,
  1473. saveEdit,
  1474. submitNewComment,
  1475. deleteComment,
  1476. unresolveComment,
  1477. refreshRanges,
  1478. deleteThread,
  1479. toggleTrackChangesForEveryone,
  1480. toggleTrackChangesForUser,
  1481. toggleTrackChangesForGuests,
  1482. setCollapsed,
  1483. setShouldCollapse,
  1484. setIsAddingComment,
  1485. setNavHeight,
  1486. setToolbarHeight,
  1487. setLayoutSuspended,
  1488. setUnsavedComment,
  1489. ]
  1490. )
  1491. return { values, updaterFns }
  1492. }
  1493. export default useReviewPanelState