threads-context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import {
  2. createContext,
  3. FC,
  4. useCallback,
  5. useContext,
  6. useEffect,
  7. useMemo,
  8. useState,
  9. } from 'react'
  10. import { useProjectContext } from '@/shared/context/project-context'
  11. import {
  12. CommentId,
  13. ReviewPanelCommentThreadMessage,
  14. ThreadId,
  15. } from '../../../../../types/review-panel/review-panel'
  16. import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread'
  17. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  18. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  19. import { UserId } from '../../../../../types/user'
  20. import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
  21. import RangesTracker from '@overleaf/ranges-tracker'
  22. import { CommentOperation } from '../../../../../types/change'
  23. import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
  24. import { useEditorContext } from '@/shared/context/editor-context'
  25. import { debugConsole } from '@/utils/debugging'
  26. import { captureException } from '@/infrastructure/error-reporter'
  27. import {
  28. AddCommentOperation,
  29. DeleteCommentOperation,
  30. SetCommentStateOperation,
  31. } from 'overleaf-editor-core'
  32. import Range from 'overleaf-editor-core/lib/range'
  33. import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
  34. import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
  35. import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
  36. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  37. import { useReviewPanelViewContext } from './review-panel-view-context'
  38. export type Threads = Record<ThreadId, ReviewPanelCommentThread>
  39. export const ThreadsContext = createContext<Threads | undefined>(undefined)
  40. type ThreadsActions = {
  41. addComment: (pos: number, text: string, content: string) => Promise<void>
  42. resolveThread: (threadId: ThreadId) => Promise<void>
  43. reopenThread: (threadId: ThreadId) => Promise<void>
  44. deleteThread: (threadId: ThreadId) => Promise<void>
  45. addMessage: (threadId: ThreadId, content: string) => Promise<void>
  46. editMessage: (
  47. threadId: ThreadId,
  48. commentId: CommentId,
  49. content: string
  50. ) => Promise<void>
  51. deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
  52. deleteOwnMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
  53. }
  54. const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
  55. undefined
  56. )
  57. export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
  58. const { projectId } = useProjectContext()
  59. const { currentDocument } = useEditorOpenDocContext()
  60. const { isRestrictedTokenMember } = useEditorContext()
  61. const view = useCodeMirrorViewContext()
  62. const { sendEvent } = useEditorAnalytics()
  63. const reviewPanelView = useReviewPanelViewContext()
  64. // const [error, setError] = useState<Error>()
  65. const [data, setData] = useState<Threads>()
  66. const isHistoryOT = currentDocument?.isHistoryOT()
  67. // load the initial threads data
  68. useEffect(() => {
  69. if (isRestrictedTokenMember) {
  70. return
  71. }
  72. const abortController = new AbortController()
  73. getJSON(`/project/${projectId}/threads`, {
  74. signal: abortController.signal,
  75. })
  76. .then(data => {
  77. setData(data)
  78. })
  79. .catch(error => {
  80. debugConsole.error(error)
  81. captureException(error)
  82. // setError(error)
  83. })
  84. }, [projectId, isRestrictedTokenMember])
  85. const { socket } = useConnectionContext()
  86. useSocketListener(
  87. socket,
  88. 'new-comment',
  89. useCallback(
  90. (
  91. threadId: ThreadId,
  92. comment: ReviewPanelCommentThreadMessage & { timestamp: number }
  93. ) => {
  94. setData(value => {
  95. if (value) {
  96. const { submitting: _submitting, ...thread } = value[threadId] ?? {
  97. messages: [],
  98. }
  99. return {
  100. ...value,
  101. [threadId]: {
  102. ...thread,
  103. messages: [
  104. ...thread.messages,
  105. {
  106. ...comment,
  107. user: comment.user, // TODO
  108. timestamp: new Date(comment.timestamp),
  109. },
  110. ],
  111. },
  112. }
  113. }
  114. })
  115. },
  116. []
  117. )
  118. )
  119. useSocketListener(
  120. socket,
  121. 'edit-message',
  122. useCallback((threadId: ThreadId, commentId: CommentId, content: string) => {
  123. setData(value => {
  124. if (value) {
  125. const thread = value[threadId] ?? { messages: [] }
  126. return {
  127. ...value,
  128. [threadId]: {
  129. ...thread,
  130. messages: thread.messages.map(message =>
  131. message.id === commentId ? { ...message, content } : message
  132. ),
  133. },
  134. }
  135. }
  136. })
  137. }, [])
  138. )
  139. useSocketListener(
  140. socket,
  141. 'delete-message',
  142. useCallback((threadId: ThreadId, commentId: CommentId) => {
  143. setData(value => {
  144. if (value) {
  145. const thread = value[threadId] ?? { messages: [] }
  146. return {
  147. ...value,
  148. [threadId]: {
  149. ...thread,
  150. messages: thread.messages.filter(
  151. message => message.id !== commentId
  152. ),
  153. },
  154. }
  155. }
  156. })
  157. }, [])
  158. )
  159. useSocketListener(
  160. socket,
  161. 'resolve-thread',
  162. useCallback(
  163. (
  164. threadId: ThreadId,
  165. user: { email: string; first_name: string; id: UserId }
  166. ) => {
  167. setData(value => {
  168. if (value) {
  169. const thread = value[threadId] ?? { messages: [] }
  170. return {
  171. ...value,
  172. [threadId]: {
  173. ...thread,
  174. resolved: true,
  175. resolved_by_user: user, // TODO
  176. resolved_at: new Date().toISOString(),
  177. },
  178. }
  179. }
  180. })
  181. },
  182. []
  183. )
  184. )
  185. useSocketListener(
  186. socket,
  187. 'reopen-thread',
  188. useCallback((threadId: ThreadId) => {
  189. setData(value => {
  190. if (value) {
  191. const thread = value[threadId] ?? { messages: [] }
  192. return {
  193. ...value,
  194. [threadId]: {
  195. ...thread,
  196. resolved: undefined,
  197. resolved_by_user: undefined,
  198. resolved_at: undefined,
  199. },
  200. }
  201. }
  202. })
  203. }, [])
  204. )
  205. useSocketListener(
  206. socket,
  207. 'delete-thread',
  208. useCallback((threadId: ThreadId) => {
  209. setData(value => {
  210. if (value) {
  211. const _value = { ...value }
  212. delete _value[threadId]
  213. return _value
  214. }
  215. })
  216. }, [])
  217. )
  218. useSocketListener(
  219. socket,
  220. 'new-comment-threads',
  221. useCallback((threads: any) => {
  222. setData(prevState => {
  223. const newThreads = { ...prevState }
  224. for (const threadId of Object.keys(threads)) {
  225. const thread = threads[threadId]
  226. const newThreadData: ReviewPanelCommentThread = {
  227. messages: [],
  228. resolved: thread.resolved,
  229. resolved_at: thread.resolved_at,
  230. resolved_by_user_id: thread.resolved_by_user_id,
  231. resolved_by_user: thread.resolved_by_user,
  232. }
  233. for (const message of thread.messages) {
  234. newThreadData.messages.push({
  235. ...message,
  236. timestamp: new Date(message.timestamp),
  237. })
  238. }
  239. newThreads[threadId as ThreadId] = newThreadData
  240. }
  241. return newThreads
  242. })
  243. }, [])
  244. )
  245. const actions = useMemo(() => {
  246. if (!currentDocument) {
  247. return
  248. }
  249. const actions = {
  250. async addComment(pos: number, text: string, content: string) {
  251. const threadId = RangesTracker.generateId() as ThreadId
  252. await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
  253. body: { content },
  254. })
  255. sendEvent('rp-new-comment', { size: content.length })
  256. const op: CommentOperation = {
  257. c: text,
  258. p: pos,
  259. t: threadId,
  260. }
  261. currentDocument.submitOp(op)
  262. },
  263. async resolveThread(threadId: string) {
  264. await postJSON(
  265. `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/resolve`
  266. )
  267. sendEvent('rp-comment-resolve', { view: reviewPanelView })
  268. },
  269. async reopenThread(threadId: string) {
  270. await postJSON(
  271. `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/reopen`
  272. )
  273. sendEvent('rp-comment-reopen')
  274. },
  275. async deleteThread(threadId: string) {
  276. await deleteJSON(
  277. `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}`
  278. )
  279. currentDocument.ranges?.removeCommentId(threadId)
  280. sendEvent('rp-comment-delete')
  281. },
  282. async addMessage(threadId: ThreadId, content: string) {
  283. await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
  284. body: { content },
  285. })
  286. sendEvent('rp-comment-reply', {
  287. view: reviewPanelView,
  288. size: content.length,
  289. thread: threadId,
  290. })
  291. },
  292. async editMessage(
  293. threadId: ThreadId,
  294. commentId: CommentId,
  295. content: string
  296. ) {
  297. await postJSON(
  298. `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`,
  299. { body: { content } }
  300. )
  301. },
  302. async deleteMessage(threadId: ThreadId, commentId: CommentId) {
  303. await deleteJSON(
  304. `/project/${projectId}/thread/${threadId}/messages/${commentId}`
  305. )
  306. },
  307. async deleteOwnMessage(threadId: ThreadId, commentId: CommentId) {
  308. await deleteJSON(
  309. `/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
  310. )
  311. },
  312. }
  313. if (isHistoryOT) {
  314. // TODO: dispatch on view instead?
  315. Object.assign(actions, {
  316. async addComment(pos: number, text: string, content: string) {
  317. const threadId = RangesTracker.generateId() as ThreadId // TODO
  318. await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
  319. body: { content },
  320. })
  321. sendEvent('rp-new-comment', { size: content.length })
  322. const trackedDeletes = trackedDeletesFromState(view.state)
  323. pos = trackedDeletes.toSnapshot(pos)
  324. const ranges = [new Range(pos, text.length)]
  325. const op = new AddCommentOperation(threadId, ranges)
  326. currentDocument.historyOTShareDoc.submitOp([op])
  327. view.dispatch({
  328. effects: rangesUpdatedEffect.of(null),
  329. })
  330. },
  331. async resolveThread(threadId: string) {
  332. const op = new SetCommentStateOperation(threadId, true)
  333. currentDocument.historyOTShareDoc.submitOp([op])
  334. sendEvent('rp-comment-resolve', { view: reviewPanelView })
  335. view.dispatch({
  336. effects: rangesUpdatedEffect.of(null),
  337. })
  338. },
  339. async reopenThread(threadId: string) {
  340. const op = new SetCommentStateOperation(threadId, false)
  341. currentDocument.historyOTShareDoc.submitOp([op])
  342. sendEvent('rp-comment-reopen')
  343. view.dispatch({
  344. effects: rangesUpdatedEffect.of(null),
  345. })
  346. },
  347. async deleteThread(threadId: string) {
  348. const op = new DeleteCommentOperation(threadId)
  349. currentDocument.historyOTShareDoc.submitOp([op])
  350. sendEvent('rp-comment-delete')
  351. view.dispatch({
  352. effects: rangesUpdatedEffect.of(null),
  353. })
  354. },
  355. })
  356. }
  357. return actions
  358. }, [
  359. view,
  360. reviewPanelView,
  361. currentDocument,
  362. projectId,
  363. isHistoryOT,
  364. sendEvent,
  365. ])
  366. if (!actions) {
  367. return null
  368. }
  369. return (
  370. <ThreadsActionsContext.Provider value={actions}>
  371. <ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
  372. </ThreadsActionsContext.Provider>
  373. )
  374. }
  375. export const useThreadsContext = () => {
  376. return useContext(ThreadsContext)
  377. }
  378. export const useThreadsActionsContext = () => {
  379. const context = useContext(ThreadsActionsContext)
  380. if (!context) {
  381. throw new Error(
  382. 'useThreadsActionsContext is only available inside ThreadsProvider'
  383. )
  384. }
  385. return context
  386. }