use-review-panel-state.ts 48 KB

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