import { createContext, FC, useCallback, useContext, useEffect, useMemo, useState, } from 'react' import { useProjectContext } from '@/shared/context/project-context' import { CommentId, ThreadId, } from '../../../../../types/review-panel/review-panel' import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { ReviewPanelCommentThreadMessageApi } from '../../../../../types/review-panel/api' import { UserId } from '../../../../../types/user' import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' import RangesTracker from '@overleaf/ranges-tracker' import { CommentOperation } from '../../../../../types/change' import useScopeValue from '@/shared/hooks/use-scope-value' import { DocumentContainer } from '@/features/ide-react/editor/document-container' export type Threads = Record export const ThreadsContext = createContext(undefined) type ThreadsActions = { addComment: (pos: number, text: string, content: string) => Promise resolveThread: (threadId: ThreadId) => Promise reopenThread: (threadId: ThreadId) => Promise deleteThread: (threadId: ThreadId) => Promise addMessage: (threadId: ThreadId, content: string) => Promise editMessage: ( threadId: ThreadId, commentId: CommentId, content: string ) => Promise deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise } const ThreadsActionsContext = createContext( undefined ) export const ThreadsProvider: FC = ({ children }) => { const { _id: projectId } = useProjectContext() const [currentDoc] = useScopeValue( 'editor.sharejs_doc' ) // const [error, setError] = useState() const [data, setData] = useState() // load the initial threads data useEffect(() => { const abortController = new AbortController() getJSON(`/project/${projectId}/threads`, { signal: abortController.signal, }).then(data => { setData(data) }) // .catch(error => { // setError(error) // }) }, [projectId]) const { socket } = useConnectionContext() useSocketListener( socket, 'new-comment', useCallback( (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => { setData(value => { if (value) { const { submitting, ...thread } = value[threadId] ?? { messages: [], } return { ...value, [threadId]: { ...thread, messages: [ ...thread.messages, { ...comment, user: comment.user, // TODO timestamp: new Date(comment.timestamp), }, ], }, } } }) }, [] ) ) useSocketListener( socket, 'edit-message', useCallback((threadId: ThreadId, commentId: CommentId, content: string) => { setData(value => { if (value) { const thread = value[threadId] ?? { messages: [] } return { ...value, [threadId]: { ...thread, messages: thread.messages.map(message => message.id === commentId ? { ...message, content } : message ), }, } } }) }, []) ) useSocketListener( socket, 'delete-message', useCallback((threadId: ThreadId, commentId: CommentId) => { setData(value => { if (value) { const thread = value[threadId] ?? { messages: [] } return { ...value, [threadId]: { ...thread, messages: thread.messages.filter( message => message.id !== commentId ), }, } } }) }, []) ) useSocketListener( socket, 'resolve-thread', useCallback( ( threadId: ThreadId, user: { email: string; first_name: string; id: UserId } ) => { setData(value => { if (value) { const thread = value[threadId] ?? { messages: [] } return { ...value, [threadId]: { ...thread, resolved: true, resolved_by_user: user, // TODO resolved_at: new Date().toISOString(), }, } } }) }, [] ) ) useSocketListener( socket, 'reopen-thread', useCallback((threadId: ThreadId) => { setData(value => { if (value) { const thread = value[threadId] ?? { messages: [] } return { ...value, [threadId]: { ...thread, resolved: undefined, resolved_by_user: undefined, resolved_at: undefined, }, } } }) }, []) ) useSocketListener( socket, 'delete-thread', useCallback((threadId: ThreadId) => { setData(value => { if (value) { const _value = { ...value } delete _value[threadId] return _value } }) }, []) ) const actions = useMemo( () => ({ async addComment(pos: number, text: string, content: string) { const threadId = RangesTracker.generateId() as ThreadId await postJSON(`/project/${projectId}/thread/${threadId}/messages`, { body: { content }, }) const op: CommentOperation = { c: text, p: pos, t: threadId, } currentDoc?.submitOp(op) }, async resolveThread(threadId: string) { await postJSON( `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/resolve` ) }, async reopenThread(threadId: string) { await postJSON( `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/reopen` ) }, async deleteThread(threadId: string) { await deleteJSON( `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}` ) currentDoc?.ranges?.removeCommentId(threadId) }, async addMessage(threadId: ThreadId, content: string) { await postJSON(`/project/${projectId}/thread/${threadId}/messages`, { body: { content }, }) // TODO: error_submitting_comment }, async editMessage( threadId: ThreadId, commentId: CommentId, content: string ) { await postJSON( `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`, { body: { content } } ) }, async deleteMessage(threadId: ThreadId, commentId: CommentId) { await deleteJSON( `/project/${projectId}/thread/${threadId}/messages/${commentId}` ) }, }), [currentDoc, projectId] ) return ( {children} ) } export const useThreadsContext = () => { return useContext(ThreadsContext) } export const useThreadsActionsContext = () => { const context = useContext(ThreadsActionsContext) if (!context) { throw new Error( 'useThreadsActionsContext is only available inside ThreadsProvider' ) } return context }