use-review-panel-state.ts 49 KB

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