threads-context.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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 { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  24. import { useEditorContext } from '@/shared/context/editor-context'
  25. import { debugConsole } from '@/utils/debugging'
  26. import { captureException } from '@/infrastructure/error-reporter'
  27. export type Threads = Record<ThreadId, ReviewPanelCommentThread>
  28. export const ThreadsContext = createContext<Threads | undefined>(undefined)
  29. type ThreadsActions = {
  30. addComment: (pos: number, text: string, content: string) => Promise<void>
  31. resolveThread: (threadId: ThreadId) => Promise<void>
  32. reopenThread: (threadId: ThreadId) => Promise<void>
  33. deleteThread: (threadId: ThreadId) => Promise<void>
  34. addMessage: (threadId: ThreadId, content: string) => Promise<void>
  35. editMessage: (
  36. threadId: ThreadId,
  37. commentId: CommentId,
  38. content: string
  39. ) => Promise<void>
  40. deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
  41. deleteOwnMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
  42. }
  43. const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
  44. undefined
  45. )
  46. export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
  47. const { _id: projectId } = useProjectContext()
  48. const { currentDocument } = useEditorManagerContext()
  49. const { isRestrictedTokenMember } = useEditorContext()
  50. // const [error, setError] = useState<Error>()
  51. const [data, setData] = useState<Threads>()
  52. // load the initial threads data
  53. useEffect(() => {
  54. if (isRestrictedTokenMember) {
  55. return
  56. }
  57. const abortController = new AbortController()
  58. getJSON(`/project/${projectId}/threads`, {
  59. signal: abortController.signal,
  60. })
  61. .then(data => {
  62. setData(data)
  63. })
  64. .catch(error => {
  65. debugConsole.error(error)
  66. captureException(error)
  67. // setError(error)
  68. })
  69. }, [projectId, isRestrictedTokenMember])
  70. const { socket } = useConnectionContext()
  71. useSocketListener(
  72. socket,
  73. 'new-comment',
  74. useCallback(
  75. (
  76. threadId: ThreadId,
  77. comment: ReviewPanelCommentThreadMessage & { timestamp: number }
  78. ) => {
  79. setData(value => {
  80. if (value) {
  81. const { submitting, ...thread } = value[threadId] ?? {
  82. messages: [],
  83. }
  84. return {
  85. ...value,
  86. [threadId]: {
  87. ...thread,
  88. messages: [
  89. ...thread.messages,
  90. {
  91. ...comment,
  92. user: comment.user, // TODO
  93. timestamp: new Date(comment.timestamp),
  94. },
  95. ],
  96. },
  97. }
  98. }
  99. })
  100. },
  101. []
  102. )
  103. )
  104. useSocketListener(
  105. socket,
  106. 'edit-message',
  107. useCallback((threadId: ThreadId, commentId: CommentId, content: string) => {
  108. setData(value => {
  109. if (value) {
  110. const thread = value[threadId] ?? { messages: [] }
  111. return {
  112. ...value,
  113. [threadId]: {
  114. ...thread,
  115. messages: thread.messages.map(message =>
  116. message.id === commentId ? { ...message, content } : message
  117. ),
  118. },
  119. }
  120. }
  121. })
  122. }, [])
  123. )
  124. useSocketListener(
  125. socket,
  126. 'delete-message',
  127. useCallback((threadId: ThreadId, commentId: CommentId) => {
  128. setData(value => {
  129. if (value) {
  130. const thread = value[threadId] ?? { messages: [] }
  131. return {
  132. ...value,
  133. [threadId]: {
  134. ...thread,
  135. messages: thread.messages.filter(
  136. message => message.id !== commentId
  137. ),
  138. },
  139. }
  140. }
  141. })
  142. }, [])
  143. )
  144. useSocketListener(
  145. socket,
  146. 'resolve-thread',
  147. useCallback(
  148. (
  149. threadId: ThreadId,
  150. user: { email: string; first_name: string; id: UserId }
  151. ) => {
  152. setData(value => {
  153. if (value) {
  154. const thread = value[threadId] ?? { messages: [] }
  155. return {
  156. ...value,
  157. [threadId]: {
  158. ...thread,
  159. resolved: true,
  160. resolved_by_user: user, // TODO
  161. resolved_at: new Date().toISOString(),
  162. },
  163. }
  164. }
  165. })
  166. },
  167. []
  168. )
  169. )
  170. useSocketListener(
  171. socket,
  172. 'reopen-thread',
  173. useCallback((threadId: ThreadId) => {
  174. setData(value => {
  175. if (value) {
  176. const thread = value[threadId] ?? { messages: [] }
  177. return {
  178. ...value,
  179. [threadId]: {
  180. ...thread,
  181. resolved: undefined,
  182. resolved_by_user: undefined,
  183. resolved_at: undefined,
  184. },
  185. }
  186. }
  187. })
  188. }, [])
  189. )
  190. useSocketListener(
  191. socket,
  192. 'delete-thread',
  193. useCallback((threadId: ThreadId) => {
  194. setData(value => {
  195. if (value) {
  196. const _value = { ...value }
  197. delete _value[threadId]
  198. return _value
  199. }
  200. })
  201. }, [])
  202. )
  203. useSocketListener(
  204. socket,
  205. 'new-comment-threads',
  206. useCallback((threads: any) => {
  207. setData(prevState => {
  208. const newThreads = { ...prevState }
  209. for (const threadId of Object.keys(threads)) {
  210. const thread = threads[threadId]
  211. const newThreadData: ReviewPanelCommentThread = {
  212. messages: [],
  213. resolved: thread.resolved,
  214. resolved_at: thread.resolved_at,
  215. resolved_by_user_id: thread.resolved_by_user_id,
  216. resolved_by_user: thread.resolved_by_user,
  217. }
  218. for (const message of thread.messages) {
  219. newThreadData.messages.push({
  220. ...message,
  221. timestamp: new Date(message.timestamp),
  222. })
  223. }
  224. newThreads[threadId as ThreadId] = newThreadData
  225. }
  226. return newThreads
  227. })
  228. }, [])
  229. )
  230. const actions = useMemo(
  231. () => ({
  232. async addComment(pos: number, text: string, content: string) {
  233. const threadId = RangesTracker.generateId() as ThreadId
  234. await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
  235. body: { content },
  236. })
  237. const op: CommentOperation = {
  238. c: text,
  239. p: pos,
  240. t: threadId,
  241. }
  242. currentDocument?.submitOp(op)
  243. },
  244. async resolveThread(threadId: string) {
  245. await postJSON(
  246. `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/resolve`
  247. )
  248. },
  249. async reopenThread(threadId: string) {
  250. await postJSON(
  251. `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/reopen`
  252. )
  253. },
  254. async deleteThread(threadId: string) {
  255. await deleteJSON(
  256. `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}`
  257. )
  258. currentDocument?.ranges?.removeCommentId(threadId)
  259. },
  260. async addMessage(threadId: ThreadId, content: string) {
  261. await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
  262. body: { content },
  263. })
  264. },
  265. async editMessage(
  266. threadId: ThreadId,
  267. commentId: CommentId,
  268. content: string
  269. ) {
  270. await postJSON(
  271. `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`,
  272. { body: { content } }
  273. )
  274. },
  275. async deleteMessage(threadId: ThreadId, commentId: CommentId) {
  276. await deleteJSON(
  277. `/project/${projectId}/thread/${threadId}/messages/${commentId}`
  278. )
  279. },
  280. async deleteOwnMessage(threadId: ThreadId, commentId: CommentId) {
  281. await deleteJSON(
  282. `/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
  283. )
  284. },
  285. }),
  286. [currentDocument, projectId]
  287. )
  288. return (
  289. <ThreadsActionsContext.Provider value={actions}>
  290. <ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
  291. </ThreadsActionsContext.Provider>
  292. )
  293. }
  294. export const useThreadsContext = () => {
  295. return useContext(ThreadsContext)
  296. }
  297. export const useThreadsActionsContext = () => {
  298. const context = useContext(ThreadsActionsContext)
  299. if (!context) {
  300. throw new Error(
  301. 'useThreadsActionsContext is only available inside ThreadsProvider'
  302. )
  303. }
  304. return context
  305. }