threads-context.tsx 8.7 KB

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