threads-context.tsx 8.9 KB

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