use-review-panel-state.ts 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619
  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. if (project.owner === user.id) {
  486. return 'member'
  487. }
  488. for (const member of project.members as any[]) {
  489. if (member._id === user.id) {
  490. return 'member'
  491. }
  492. }
  493. return 'guest'
  494. }, [project.members, project.owner, user])
  495. const applyClientTrackChangesStateToServer = useCallback(
  496. (
  497. trackChangesOnForEveryone: boolean,
  498. trackChangesOnForGuests: boolean,
  499. trackChangesState: ReviewPanel.Value<'trackChangesState'>
  500. ) => {
  501. const data: {
  502. on?: boolean
  503. on_for?: Record<UserId, boolean>
  504. on_for_guests?: boolean
  505. } = {}
  506. if (trackChangesOnForEveryone) {
  507. data.on = true
  508. } else {
  509. data.on_for = {}
  510. const entries = Object.entries(trackChangesState) as Array<
  511. [
  512. UserId,
  513. NonNullable<
  514. typeof trackChangesState[keyof typeof trackChangesState]
  515. >
  516. ]
  517. >
  518. for (const [userId, { value }] of entries) {
  519. data.on_for[userId] = value
  520. }
  521. if (trackChangesOnForGuests) {
  522. data.on_for_guests = true
  523. }
  524. }
  525. postJSON(`/project/${projectId}/track_changes`, {
  526. body: data,
  527. }).catch(debugConsole.error)
  528. },
  529. [projectId]
  530. )
  531. const setGuestsTCState = useCallback(
  532. (newValue: boolean) => {
  533. setTrackChangesOnForGuests(newValue)
  534. if (currentUserType() === 'guest' || currentUserType() === 'anonymous') {
  535. setWantTrackChanges(newValue)
  536. }
  537. },
  538. [currentUserType, setWantTrackChanges]
  539. )
  540. const setUserTCState = useCallback(
  541. (
  542. trackChangesState: DeepReadonly<ReviewPanel.Value<'trackChangesState'>>,
  543. userId: UserId,
  544. newValue: boolean,
  545. isLocal = false
  546. ) => {
  547. const newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  548. ...trackChangesState,
  549. }
  550. const state =
  551. newTrackChangesState[userId] ??
  552. ({} as NonNullable<typeof newTrackChangesState[UserId]>)
  553. newTrackChangesState[userId] = state
  554. if (state.syncState == null || state.syncState === 'synced') {
  555. state.value = newValue
  556. state.syncState = 'synced'
  557. } else if (state.syncState === 'pending' && state.value === newValue) {
  558. state.syncState = 'synced'
  559. } else if (isLocal) {
  560. state.value = newValue
  561. state.syncState = 'pending'
  562. }
  563. setTrackChangesState(newTrackChangesState)
  564. if (userId === user.id) {
  565. setWantTrackChanges(newValue)
  566. }
  567. return newTrackChangesState
  568. },
  569. [setWantTrackChanges, user.id]
  570. )
  571. const setEveryoneTCState = useCallback(
  572. (newValue: boolean, isLocal = false) => {
  573. setTrackChangesOnForEveryone(newValue)
  574. let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  575. ...trackChangesState,
  576. }
  577. for (const member of project.members as any[]) {
  578. newTrackChangesState = setUserTCState(
  579. newTrackChangesState,
  580. member._id,
  581. newValue,
  582. isLocal
  583. )
  584. }
  585. setGuestsTCState(newValue)
  586. newTrackChangesState = setUserTCState(
  587. newTrackChangesState,
  588. project.owner._id,
  589. newValue,
  590. isLocal
  591. )
  592. return { trackChangesState: newTrackChangesState }
  593. },
  594. [
  595. project.members,
  596. project.owner._id,
  597. setGuestsTCState,
  598. setUserTCState,
  599. trackChangesState,
  600. ]
  601. )
  602. const toggleTrackChangesForEveryone = useCallback<
  603. ReviewPanel.UpdaterFn<'toggleTrackChangesForEveryone'>
  604. >(
  605. (onForEveryone: boolean) => {
  606. const { trackChangesState } = setEveryoneTCState(onForEveryone, true)
  607. setGuestsTCState(onForEveryone)
  608. applyClientTrackChangesStateToServer(
  609. onForEveryone,
  610. onForEveryone,
  611. trackChangesState
  612. )
  613. },
  614. [applyClientTrackChangesStateToServer, setEveryoneTCState, setGuestsTCState]
  615. )
  616. const toggleTrackChangesForGuests = useCallback<
  617. ReviewPanel.UpdaterFn<'toggleTrackChangesForGuests'>
  618. >(
  619. (onForGuests: boolean) => {
  620. setGuestsTCState(onForGuests)
  621. applyClientTrackChangesStateToServer(
  622. trackChangesOnForEveryone,
  623. onForGuests,
  624. trackChangesState
  625. )
  626. },
  627. [
  628. applyClientTrackChangesStateToServer,
  629. setGuestsTCState,
  630. trackChangesOnForEveryone,
  631. trackChangesState,
  632. ]
  633. )
  634. const toggleTrackChangesForUser = useCallback<
  635. ReviewPanel.UpdaterFn<'toggleTrackChangesForUser'>
  636. >(
  637. (onForUser: boolean, userId: UserId) => {
  638. const newTrackChangesState = setUserTCState(
  639. trackChangesState,
  640. userId,
  641. onForUser,
  642. true
  643. )
  644. applyClientTrackChangesStateToServer(
  645. trackChangesOnForEveryone,
  646. trackChangesOnForGuests,
  647. newTrackChangesState
  648. )
  649. },
  650. [
  651. applyClientTrackChangesStateToServer,
  652. setUserTCState,
  653. trackChangesOnForEveryone,
  654. trackChangesOnForGuests,
  655. trackChangesState,
  656. ]
  657. )
  658. const applyTrackChangesStateToClient = useCallback(
  659. (state: boolean | Record<UserId, boolean>) => {
  660. if (typeof state === 'boolean') {
  661. setEveryoneTCState(state)
  662. setGuestsTCState(state)
  663. } else {
  664. setTrackChangesOnForEveryone(false)
  665. // TODO
  666. // @ts-ignore
  667. setGuestsTCState(state.__guests__ === true)
  668. let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
  669. ...trackChangesState,
  670. }
  671. for (const member of project.members as any[]) {
  672. newTrackChangesState = setUserTCState(
  673. newTrackChangesState,
  674. member._id,
  675. state[member._id] ?? false
  676. )
  677. }
  678. newTrackChangesState = setUserTCState(
  679. newTrackChangesState,
  680. project.owner._id,
  681. state[project.owner._id] ?? false
  682. )
  683. return newTrackChangesState
  684. }
  685. },
  686. [
  687. project.members,
  688. project.owner._id,
  689. setEveryoneTCState,
  690. setGuestsTCState,
  691. setUserTCState,
  692. trackChangesState,
  693. ]
  694. )
  695. const setGuestFeatureBasedOnProjectAccessLevel = (
  696. projectPublicAccessLevel: PublicAccessLevel
  697. ) => {
  698. setTrackChangesForGuestsAvailable(projectPublicAccessLevel === 'tokenBased')
  699. }
  700. useEffect(() => {
  701. setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
  702. }, [project.publicAccessLevel])
  703. useEffect(() => {
  704. if (
  705. trackChangesForGuestsAvailable ||
  706. !trackChangesOnForGuests ||
  707. trackChangesOnForEveryone
  708. ) {
  709. return
  710. }
  711. // Overrides guest setting
  712. toggleTrackChangesForGuests(false)
  713. }, [
  714. toggleTrackChangesForGuests,
  715. trackChangesForGuestsAvailable,
  716. trackChangesOnForEveryone,
  717. trackChangesOnForGuests,
  718. ])
  719. const projectJoinedEffectExecuted = useRef(false)
  720. useEffect(() => {
  721. if (!projectJoinedEffectExecuted.current) {
  722. projectJoinedEffectExecuted.current = true
  723. requestAnimationFrame(() => {
  724. if (trackChanges) {
  725. applyTrackChangesStateToClient(project.trackChangesState)
  726. } else {
  727. applyTrackChangesStateToClient(false)
  728. }
  729. setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
  730. })
  731. }
  732. }, [
  733. applyTrackChangesStateToClient,
  734. trackChanges,
  735. project.publicAccessLevel,
  736. project.trackChangesState,
  737. ])
  738. useEffect(() => {
  739. setFormattedProjectMembers(prevState => {
  740. const tempFormattedProjectMembers: typeof prevState = {}
  741. if (project.owner) {
  742. tempFormattedProjectMembers[project.owner._id] = formatUser(
  743. project.owner
  744. )
  745. }
  746. const members = project.members ?? []
  747. for (const member of members) {
  748. if (member.privileges === 'readAndWrite') {
  749. if (!trackChangesState[member._id]) {
  750. // An added member will have track changes enabled if track changes is on for everyone
  751. setUserTCState(
  752. trackChangesState,
  753. member._id,
  754. trackChangesOnForEveryone,
  755. true
  756. )
  757. }
  758. tempFormattedProjectMembers[member._id] = formatUser(member)
  759. }
  760. }
  761. return tempFormattedProjectMembers
  762. })
  763. }, [
  764. project.members,
  765. project.owner,
  766. setUserTCState,
  767. trackChangesOnForEveryone,
  768. trackChangesState,
  769. ])
  770. useSocketListener(
  771. socket,
  772. 'toggle-track-changes',
  773. applyTrackChangesStateToClient
  774. )
  775. const gotoEntry = useCallback(
  776. (docId: DocId, entryOffset: number) => {
  777. openDocId(docId, { gotoOffset: entryOffset })
  778. },
  779. [openDocId]
  780. )
  781. const view = reviewPanelOpen ? subView : 'mini'
  782. const toggleReviewPanel = useCallback(() => {
  783. if (!trackChangesVisible) {
  784. return
  785. }
  786. setReviewPanelOpen(!reviewPanelOpen)
  787. sendMB('rp-toggle-panel', {
  788. value: !reviewPanelOpen,
  789. })
  790. }, [reviewPanelOpen, setReviewPanelOpen, trackChangesVisible])
  791. const onCommentResolved = useCallback(
  792. (threadId: ThreadId, user: any) => {
  793. setCommentThreads(prevState => {
  794. const thread = { ...getThread(threadId) }
  795. thread.resolved = true
  796. thread.resolved_by_user = formatUser(user)
  797. thread.resolved_at = new Date().toISOString() as DateString
  798. return { ...prevState, [threadId]: thread }
  799. })
  800. setResolvedThreadIds(prevState => ({ ...prevState, [threadId]: true }))
  801. dispatchReviewPanelEvent('comment:resolve_threads', [threadId])
  802. },
  803. [getThread]
  804. )
  805. const resolveComment = useCallback(
  806. (docId: DocId, entryId: ThreadId) => {
  807. const docEntries = getDocEntries(docId)
  808. const entry = docEntries[entryId] as ReviewPanelCommentEntry
  809. setEntries(prevState => ({
  810. ...prevState,
  811. [docId]: {
  812. ...prevState[docId],
  813. [entryId]: {
  814. ...prevState[docId][entryId],
  815. focused: false,
  816. },
  817. },
  818. }))
  819. postJSON(`/project/${projectId}/thread/${entry.thread_id}/resolve`)
  820. onCommentResolved(entry.thread_id, user)
  821. sendMB('rp-comment-resolve', { view })
  822. },
  823. [getDocEntries, onCommentResolved, projectId, user, view]
  824. )
  825. const onCommentReopened = useCallback(
  826. (threadId: ThreadId) => {
  827. setCommentThreads(prevState => {
  828. const {
  829. resolved: _1,
  830. resolved_by_user: _2,
  831. resolved_at: _3,
  832. ...thread
  833. } = getThread(threadId)
  834. return { ...prevState, [threadId]: thread }
  835. })
  836. setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => {
  837. return resolvedThreadIds
  838. })
  839. dispatchReviewPanelEvent('comment:unresolve_thread', threadId)
  840. },
  841. [getThread]
  842. )
  843. const unresolveComment = useCallback(
  844. (threadId: ThreadId) => {
  845. onCommentReopened(threadId)
  846. const url = `/project/${projectId}/thread/${threadId}/reopen`
  847. postJSON(url).catch(debugConsole.error)
  848. sendMB('rp-comment-reopen')
  849. },
  850. [onCommentReopened, projectId]
  851. )
  852. const onThreadDeleted = useCallback((threadId: ThreadId) => {
  853. setResolvedThreadIds(({ [threadId]: _, ...resolvedThreadIds }) => {
  854. return resolvedThreadIds
  855. })
  856. setCommentThreads(({ [threadId]: _, ...commentThreads }) => {
  857. return commentThreads
  858. })
  859. dispatchReviewPanelEvent('comment:remove', threadId)
  860. }, [])
  861. const deleteThread = useCallback(
  862. (docId: DocId, threadId: ThreadId) => {
  863. onThreadDeleted(threadId)
  864. deleteJSON(`/project/${projectId}/doc/${docId}/thread/${threadId}`).catch(
  865. debugConsole.error
  866. )
  867. sendMB('rp-comment-delete')
  868. },
  869. [onThreadDeleted, projectId]
  870. )
  871. const onCommentEdited: ReviewPanel.UpdaterFn<'saveEdit'> = (
  872. threadId: ThreadId,
  873. commentId: CommentId,
  874. content: string
  875. ) => {
  876. setCommentThreads(prevState => {
  877. const thread = { ...getThread(threadId) }
  878. thread.messages = thread.messages.map(message => {
  879. return message.id === commentId ? { ...message, content } : message
  880. })
  881. return { ...prevState, [threadId]: thread }
  882. })
  883. }
  884. const saveEdit = useCallback(
  885. (threadId: ThreadId, commentId: CommentId, content: string) => {
  886. const url = `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`
  887. postJSON(url, { body: { content } }).catch(debugConsole.error)
  888. handleLayoutChange({ async: true })
  889. },
  890. [projectId]
  891. )
  892. const onCommentDeleted = useCallback(
  893. (threadId: ThreadId, commentId: CommentId) => {
  894. setCommentThreads(prevState => {
  895. const thread = { ...getThread(threadId) }
  896. thread.messages = thread.messages.filter(m => m.id !== commentId)
  897. return { ...prevState, [threadId]: thread }
  898. })
  899. },
  900. [getThread]
  901. )
  902. const deleteComment = useCallback(
  903. (threadId: ThreadId, commentId: CommentId) => {
  904. onCommentDeleted(threadId, commentId)
  905. deleteJSON(
  906. `/project/${projectId}/thread/${threadId}/messages/${commentId}`
  907. ).catch(debugConsole.error)
  908. handleLayoutChange({ async: true })
  909. },
  910. [onCommentDeleted, projectId]
  911. )
  912. const doAcceptChanges = useCallback(
  913. (entryIds: ThreadId[]) => {
  914. const url = `/project/${projectId}/doc/${currentDocumentId}/changes/accept`
  915. postJSON(url, { body: { change_ids: entryIds } }).catch(
  916. debugConsole.error
  917. )
  918. dispatchReviewPanelEvent('changes:accept', entryIds)
  919. },
  920. [currentDocumentId, projectId]
  921. )
  922. const acceptChanges = useCallback(
  923. (entryIds: ThreadId[]) => {
  924. doAcceptChanges(entryIds)
  925. sendMB('rp-changes-accepted', { view })
  926. },
  927. [doAcceptChanges, view]
  928. )
  929. const doRejectChanges = useCallback((entryIds: ThreadId[]) => {
  930. dispatchReviewPanelEvent('changes:reject', entryIds)
  931. }, [])
  932. const rejectChanges = useCallback(
  933. (entryIds: ThreadId[]) => {
  934. doRejectChanges(entryIds)
  935. sendMB('rp-changes-rejected', { view })
  936. },
  937. [doRejectChanges, view]
  938. )
  939. const bulkAcceptActions = useCallback(() => {
  940. doAcceptChanges(selectedEntryIds.current)
  941. sendMB('rp-bulk-accept', { view, nEntries: nVisibleSelectedChanges })
  942. }, [doAcceptChanges, nVisibleSelectedChanges, view])
  943. const bulkRejectActions = useCallback(() => {
  944. doRejectChanges(selectedEntryIds.current)
  945. sendMB('rp-bulk-reject', { view, nEntries: nVisibleSelectedChanges })
  946. }, [doRejectChanges, nVisibleSelectedChanges, view])
  947. const refreshRanges = useCallback(() => {
  948. type Doc = {
  949. id: DocId
  950. ranges: {
  951. comments?: unknown[]
  952. changes?: unknown[]
  953. }
  954. }
  955. return getJSON<Doc[]>(`/project/${projectId}/ranges`)
  956. .then(docs => {
  957. setCollapsed(prevState => {
  958. const collapsed = { ...prevState }
  959. docs.forEach(doc => {
  960. if (collapsed[doc.id] == null) {
  961. collapsed[doc.id] = false
  962. }
  963. })
  964. return collapsed
  965. })
  966. docs.forEach(async doc => {
  967. if (doc.id !== currentDocumentId) {
  968. // this is kept up to date in real-time, don't overwrite
  969. const rangesTracker = getChangeTracker(doc.id)
  970. rangesTracker.comments = doc.ranges?.comments ?? []
  971. rangesTracker.changes = doc.ranges?.changes ?? []
  972. }
  973. })
  974. return Promise.all(docs.map(doc => updateEntries(doc.id)))
  975. })
  976. .catch(debugConsole.error)
  977. }, [
  978. currentDocumentId,
  979. getChangeTracker,
  980. projectId,
  981. setCollapsed,
  982. updateEntries,
  983. ])
  984. const handleSetSubview = useCallback((subView: SubView) => {
  985. setSubView(subView)
  986. sendMB('rp-subview-change', { subView })
  987. }, [])
  988. const submitReply = useCallback(
  989. (threadId: ThreadId, replyContent: string) => {
  990. const url = `/project/${projectId}/thread/${threadId}/messages`
  991. postJSON(url, { body: { content: replyContent } }).catch(() => {
  992. showGenericMessageModal(
  993. t('error_submitting_comment'),
  994. t('comment_submit_error')
  995. )
  996. })
  997. const trackingMetadata = {
  998. view,
  999. size: replyContent.length,
  1000. thread: threadId,
  1001. }
  1002. setCommentThreads(prevState => ({
  1003. ...prevState,
  1004. [threadId]: { ...getThread(threadId), submitting: true },
  1005. }))
  1006. handleLayoutChange({ async: true })
  1007. sendMB('rp-comment-reply', trackingMetadata)
  1008. },
  1009. [getThread, projectId, showGenericMessageModal, t, view]
  1010. )
  1011. // TODO `submitNewComment` is partially localized in the `add-comment-entry` component.
  1012. const submitNewComment = useCallback(
  1013. (content: string) => {
  1014. if (!content) {
  1015. return
  1016. }
  1017. const entries = getDocEntries(currentDocumentId)
  1018. const addCommentEntry = entries['add-comment'] as
  1019. | ReviewPanelAddCommentEntry
  1020. | undefined
  1021. if (!addCommentEntry) {
  1022. return
  1023. }
  1024. const { offset, length } = addCommentEntry
  1025. const threadId = RangesTracker.generateId()
  1026. setCommentThreads(prevState => ({
  1027. ...prevState,
  1028. [threadId]: { ...getThread(threadId), submitting: true },
  1029. }))
  1030. const url = `/project/${projectId}/thread/${threadId}/messages`
  1031. postJSON(url, { body: { content } })
  1032. .then(() => {
  1033. dispatchReviewPanelEvent('comment:add', { threadId, offset, length })
  1034. handleLayoutChange({ async: true })
  1035. sendMB('rp-new-comment', { size: content.length })
  1036. })
  1037. .catch(() => {
  1038. showGenericMessageModal(
  1039. t('error_submitting_comment'),
  1040. t('comment_submit_error')
  1041. )
  1042. })
  1043. },
  1044. [
  1045. currentDocumentId,
  1046. getDocEntries,
  1047. getThread,
  1048. projectId,
  1049. showGenericMessageModal,
  1050. t,
  1051. ]
  1052. )
  1053. const [isAddingComment, setIsAddingComment] = useState(false)
  1054. const [navHeight, setNavHeight] = useState(0)
  1055. const [toolbarHeight, setToolbarHeight] = useState(0)
  1056. const [layoutSuspended, setLayoutSuspended] = useState(false)
  1057. const [unsavedComment, setUnsavedComment] = useState('')
  1058. useEffect(() => {
  1059. if (!trackChangesVisible) {
  1060. setReviewPanelOpen(false)
  1061. }
  1062. }, [trackChangesVisible, setReviewPanelOpen])
  1063. const hasEntries = useMemo(() => {
  1064. const docEntries = getDocEntries(currentDocumentId)
  1065. const permEntriesCount = Object.keys(docEntries).filter(key => {
  1066. return !['add-comment', 'bulk-actions'].includes(key)
  1067. }).length
  1068. return permEntriesCount > 0 && trackChangesVisible
  1069. }, [currentDocumentId, getDocEntries, trackChangesVisible])
  1070. useEffect(() => {
  1071. setMiniReviewPanelVisible(!reviewPanelOpen && hasEntries)
  1072. }, [reviewPanelOpen, hasEntries, setMiniReviewPanelVisible])
  1073. // listen for events from the CodeMirror 6 track changes extension
  1074. useEffect(() => {
  1075. const toggleTrackChangesFromKbdShortcut = () => {
  1076. if (trackChangesVisible && trackChanges) {
  1077. const userId: UserId = user.id
  1078. const state = trackChangesState[userId]
  1079. if (state) {
  1080. toggleTrackChangesForUser(!state.value, userId)
  1081. }
  1082. }
  1083. }
  1084. const editorLineHeightChanged = (payload: typeof lineHeight) => {
  1085. setLineHeight(payload)
  1086. handleLayoutChange()
  1087. }
  1088. const editorTrackChangesChanged = async () => {
  1089. const tempEntries = cloneDeep(await updateEntries(currentDocumentId))
  1090. // `tempEntries` would be mutated
  1091. dispatchReviewPanelEvent('recalculate-screen-positions', {
  1092. entries: tempEntries,
  1093. updateType: 'trackedChangesChange',
  1094. })
  1095. // The state should be updated after dispatching the 'recalculate-screen-positions'
  1096. // event as `tempEntries` will be mutated
  1097. setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries }))
  1098. handleLayoutChange()
  1099. }
  1100. const editorTrackChangesVisibilityChanged = () => {
  1101. handleLayoutChange({ async: true, animate: false })
  1102. }
  1103. const editorFocusChanged = (
  1104. selectionOffsetStart: number,
  1105. selectionOffsetEnd: number,
  1106. selection: boolean,
  1107. updateType: UpdateType
  1108. ) => {
  1109. let tempEntries = cloneDeep(getDocEntries(currentDocumentId))
  1110. // All selected changes will be added to this array.
  1111. selectedEntryIds.current = []
  1112. // Count of user-visible changes, i.e. an aggregated change will count as one.
  1113. let tempNVisibleSelectedChanges = 0
  1114. const offset = selectionOffsetStart
  1115. const length = selectionOffsetEnd - selectionOffsetStart
  1116. // Recreate the add comment and bulk actions entries only when
  1117. // necessary. This is to avoid the UI thinking that these entries have
  1118. // changed and getting into an infinite loop.
  1119. if (selection) {
  1120. const existingAddComment = tempEntries[
  1121. 'add-comment'
  1122. ] as ReviewPanelAddCommentEntry
  1123. if (
  1124. !existingAddComment ||
  1125. existingAddComment.offset !== offset ||
  1126. existingAddComment.length !== length
  1127. ) {
  1128. tempEntries['add-comment'] = {
  1129. type: 'add-comment',
  1130. offset,
  1131. length,
  1132. } as ReviewPanelAddCommentEntry
  1133. }
  1134. const existingBulkActions = tempEntries[
  1135. 'bulk-actions'
  1136. ] as ReviewPanelBulkActionsEntry
  1137. if (
  1138. !existingBulkActions ||
  1139. existingBulkActions.offset !== offset ||
  1140. existingBulkActions.length !== length
  1141. ) {
  1142. tempEntries['bulk-actions'] = {
  1143. type: 'bulk-actions',
  1144. offset,
  1145. length,
  1146. } as ReviewPanelBulkActionsEntry
  1147. }
  1148. } else {
  1149. delete (tempEntries as Partial<typeof tempEntries>)['add-comment']
  1150. delete (tempEntries as Partial<typeof tempEntries>)['bulk-actions']
  1151. }
  1152. for (const [key, entry] of Object.entries(tempEntries) as Entries<
  1153. typeof tempEntries
  1154. >) {
  1155. let isChangeEntryAndWithinSelection = false
  1156. if (entry.type === 'comment' && !resolvedThreadIds[entry.thread_id]) {
  1157. tempEntries = {
  1158. ...tempEntries,
  1159. [key]: {
  1160. ...tempEntries[key],
  1161. focused:
  1162. entry.offset <= selectionOffsetStart &&
  1163. selectionOffsetStart <= entry.offset + entry.content.length,
  1164. },
  1165. }
  1166. } else if (
  1167. entry.type === 'insert' ||
  1168. entry.type === 'aggregate-change'
  1169. ) {
  1170. isChangeEntryAndWithinSelection =
  1171. entry.offset >= selectionOffsetStart &&
  1172. entry.offset + entry.content.length <= selectionOffsetEnd
  1173. tempEntries = {
  1174. ...tempEntries,
  1175. [key]: {
  1176. ...tempEntries[key],
  1177. focused:
  1178. entry.offset <= selectionOffsetStart &&
  1179. selectionOffsetStart <= entry.offset + entry.content.length,
  1180. },
  1181. }
  1182. } else if (entry.type === 'delete') {
  1183. isChangeEntryAndWithinSelection =
  1184. selectionOffsetStart <= entry.offset &&
  1185. entry.offset <= selectionOffsetEnd
  1186. tempEntries = {
  1187. ...tempEntries,
  1188. [key]: {
  1189. ...tempEntries[key],
  1190. focused: entry.offset === selectionOffsetStart,
  1191. },
  1192. }
  1193. } else if (
  1194. ['add-comment', 'bulk-actions'].includes(entry.type) &&
  1195. selection
  1196. ) {
  1197. tempEntries = {
  1198. ...tempEntries,
  1199. [key]: { ...tempEntries[key], focused: true },
  1200. }
  1201. }
  1202. if (isChangeEntryAndWithinSelection) {
  1203. const entryIds = 'entry_ids' in entry ? entry.entry_ids : []
  1204. for (const entryId of entryIds) {
  1205. selectedEntryIds.current.push(entryId)
  1206. }
  1207. tempNVisibleSelectedChanges++
  1208. }
  1209. }
  1210. // `tempEntries` would be mutated
  1211. dispatchReviewPanelEvent('recalculate-screen-positions', {
  1212. entries: tempEntries,
  1213. updateType,
  1214. })
  1215. // The state should be updated after dispatching the 'recalculate-screen-positions'
  1216. // event as `tempEntries` will be mutated
  1217. setEntries(prev => ({ ...prev, [currentDocumentId]: tempEntries }))
  1218. setNVisibleSelectedChanges(tempNVisibleSelectedChanges)
  1219. handleLayoutChange()
  1220. }
  1221. const addNewCommentFromKbdShortcut = () => {
  1222. if (!trackChangesVisible) {
  1223. return
  1224. }
  1225. dispatchReviewPanelEvent('comment:select_line')
  1226. if (!reviewPanelOpen) {
  1227. toggleReviewPanel()
  1228. }
  1229. handleLayoutChange({ async: true })
  1230. addCommentEmitter()
  1231. }
  1232. const handleEditorEvents = (e: Event) => {
  1233. const event = e as CustomEvent
  1234. const { type, payload } = event.detail
  1235. switch (type) {
  1236. case 'line-height': {
  1237. editorLineHeightChanged(payload)
  1238. break
  1239. }
  1240. case 'track-changes:changed': {
  1241. editorTrackChangesChanged()
  1242. break
  1243. }
  1244. case 'track-changes:visibility_changed': {
  1245. editorTrackChangesVisibilityChanged()
  1246. break
  1247. }
  1248. case 'focus:changed': {
  1249. const { from, to, empty, updateType } = payload
  1250. editorFocusChanged(from, to, !empty, updateType)
  1251. break
  1252. }
  1253. case 'add-new-comment': {
  1254. addNewCommentFromKbdShortcut()
  1255. break
  1256. }
  1257. case 'toggle-track-changes': {
  1258. toggleTrackChangesFromKbdShortcut()
  1259. break
  1260. }
  1261. case 'toggle-review-panel': {
  1262. toggleReviewPanel()
  1263. break
  1264. }
  1265. }
  1266. }
  1267. window.addEventListener('editor:event', handleEditorEvents)
  1268. return () => {
  1269. window.removeEventListener('editor:event', handleEditorEvents)
  1270. }
  1271. }, [
  1272. addCommentEmitter,
  1273. currentDocumentId,
  1274. getDocEntries,
  1275. resolvedThreadIds,
  1276. reviewPanelOpen,
  1277. toggleReviewPanel,
  1278. toggleTrackChangesForUser,
  1279. trackChanges,
  1280. trackChangesState,
  1281. trackChangesVisible,
  1282. updateEntries,
  1283. user.id,
  1284. ])
  1285. useSocketListener(socket, 'reopen-thread', onCommentReopened)
  1286. useSocketListener(socket, 'delete-thread', onThreadDeleted)
  1287. useSocketListener(socket, 'resolve-thread', onCommentResolved)
  1288. useSocketListener(socket, 'edit-message', onCommentEdited)
  1289. useSocketListener(socket, 'delete-message', onCommentDeleted)
  1290. useSocketListener(
  1291. socket,
  1292. 'accept-changes',
  1293. useCallback(
  1294. (docId: DocId, entryIds: ThreadId[]) => {
  1295. if (docId !== currentDocumentId) {
  1296. getChangeTracker(docId).removeChangeIds(entryIds)
  1297. } else {
  1298. dispatchReviewPanelEvent('changes:accept', entryIds)
  1299. }
  1300. updateEntries(docId)
  1301. },
  1302. [currentDocumentId, getChangeTracker, updateEntries]
  1303. )
  1304. )
  1305. useSocketListener(
  1306. socket,
  1307. 'new-comment',
  1308. useCallback(
  1309. (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => {
  1310. setCommentThreads(prevState => {
  1311. const { submitting: _, ...thread } = getThread(threadId)
  1312. thread.messages = [...thread.messages]
  1313. thread.messages.push(formatComment(comment))
  1314. return { ...prevState, [threadId]: thread }
  1315. })
  1316. handleLayoutChange({ async: true })
  1317. },
  1318. [getThread]
  1319. )
  1320. )
  1321. const openSubView = useRef<typeof subView>('cur_file')
  1322. useEffect(() => {
  1323. if (!reviewPanelOpen) {
  1324. // Always show current file when not open, but save current state
  1325. setSubView(prevState => {
  1326. openSubView.current = prevState
  1327. return 'cur_file'
  1328. })
  1329. } else {
  1330. // Reset back to what we had when previously open
  1331. setSubView(openSubView.current)
  1332. }
  1333. handleLayoutChange({ async: true, animate: false })
  1334. }, [reviewPanelOpen])
  1335. const canRefreshRanges = useRef(false)
  1336. useEffect(() => {
  1337. if (subView === 'overview' && canRefreshRanges.current) {
  1338. canRefreshRanges.current = false
  1339. setIsOverviewLoading(true)
  1340. refreshRanges().finally(() => {
  1341. setIsOverviewLoading(false)
  1342. })
  1343. }
  1344. }, [subView, refreshRanges])
  1345. const prevSubView = useRef(subView)
  1346. const initializedPrevSubView = useRef(false)
  1347. useEffect(() => {
  1348. // Prevent setting a computed value for `prevSubView` on mount
  1349. if (!initializedPrevSubView.current) {
  1350. initializedPrevSubView.current = true
  1351. return
  1352. }
  1353. prevSubView.current = subView === 'cur_file' ? 'overview' : 'cur_file'
  1354. // Allow refreshing ranges once for each `subView` change
  1355. canRefreshRanges.current = true
  1356. }, [subView])
  1357. useEffect(() => {
  1358. if (subView === 'cur_file' && prevSubView.current === 'overview') {
  1359. dispatchReviewPanelEvent('overview-closed', subView)
  1360. }
  1361. }, [subView])
  1362. useEffect(() => {
  1363. if (Object.keys(users).length) {
  1364. handleLayoutChange({ async: true })
  1365. }
  1366. }, [users])
  1367. const values = useMemo<ReviewPanelStateReactIde['values']>(
  1368. () => ({
  1369. collapsed,
  1370. commentThreads,
  1371. entries,
  1372. isAddingComment,
  1373. loadingThreads,
  1374. nVisibleSelectedChanges,
  1375. permissions,
  1376. users,
  1377. resolvedComments,
  1378. shouldCollapse,
  1379. navHeight,
  1380. toolbarHeight,
  1381. subView,
  1382. wantTrackChanges,
  1383. isOverviewLoading,
  1384. openDocId: currentDocumentId,
  1385. lineHeight,
  1386. trackChangesState,
  1387. trackChangesOnForEveryone,
  1388. trackChangesOnForGuests,
  1389. trackChangesForGuestsAvailable,
  1390. formattedProjectMembers,
  1391. layoutSuspended,
  1392. unsavedComment,
  1393. layoutToLeft,
  1394. }),
  1395. [
  1396. collapsed,
  1397. commentThreads,
  1398. entries,
  1399. isAddingComment,
  1400. loadingThreads,
  1401. nVisibleSelectedChanges,
  1402. permissions,
  1403. users,
  1404. resolvedComments,
  1405. shouldCollapse,
  1406. navHeight,
  1407. toolbarHeight,
  1408. subView,
  1409. wantTrackChanges,
  1410. isOverviewLoading,
  1411. currentDocumentId,
  1412. lineHeight,
  1413. trackChangesState,
  1414. trackChangesOnForEveryone,
  1415. trackChangesOnForGuests,
  1416. trackChangesForGuestsAvailable,
  1417. formattedProjectMembers,
  1418. layoutSuspended,
  1419. unsavedComment,
  1420. layoutToLeft,
  1421. ]
  1422. )
  1423. const updaterFns = useMemo<ReviewPanelStateReactIde['updaterFns']>(
  1424. () => ({
  1425. handleSetSubview,
  1426. handleLayoutChange,
  1427. gotoEntry,
  1428. resolveComment,
  1429. submitReply,
  1430. acceptChanges,
  1431. rejectChanges,
  1432. toggleReviewPanel,
  1433. bulkAcceptActions,
  1434. bulkRejectActions,
  1435. saveEdit,
  1436. submitNewComment,
  1437. deleteComment,
  1438. unresolveComment,
  1439. refreshResolvedCommentsDropdown: refreshRanges,
  1440. deleteThread,
  1441. toggleTrackChangesForEveryone,
  1442. toggleTrackChangesForUser,
  1443. toggleTrackChangesForGuests,
  1444. setCollapsed,
  1445. setShouldCollapse,
  1446. setIsAddingComment,
  1447. setNavHeight,
  1448. setToolbarHeight,
  1449. setLayoutSuspended,
  1450. setUnsavedComment,
  1451. }),
  1452. [
  1453. handleSetSubview,
  1454. gotoEntry,
  1455. resolveComment,
  1456. submitReply,
  1457. acceptChanges,
  1458. rejectChanges,
  1459. toggleReviewPanel,
  1460. bulkAcceptActions,
  1461. bulkRejectActions,
  1462. saveEdit,
  1463. submitNewComment,
  1464. deleteComment,
  1465. unresolveComment,
  1466. refreshRanges,
  1467. deleteThread,
  1468. toggleTrackChangesForEveryone,
  1469. toggleTrackChangesForUser,
  1470. toggleTrackChangesForGuests,
  1471. setCollapsed,
  1472. setShouldCollapse,
  1473. setIsAddingComment,
  1474. setNavHeight,
  1475. setToolbarHeight,
  1476. setLayoutSuspended,
  1477. setUnsavedComment,
  1478. ]
  1479. )
  1480. return { values, updaterFns }
  1481. }
  1482. export default useReviewPanelState