use-review-panel-state.ts 48 KB

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