threads-context.tsx 7.6 KB

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