first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { UserId } from '../../../../../types/user'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type ChangesUser = {
id: UserId
email: string
first_name?: string
last_name?: string
}
export type ChangesUsers = Map<UserId, ChangesUser>
export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
undefined
)
export const ChangesUsersProvider: FC = ({ children }) => {
const { _id: projectId, members, owner } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
useEffect(() => {
if (isRestrictedTokenMember) {
return
}
getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`)
.then(data => setChangesUsers(new Map(data.map(item => [item.id, item]))))
.catch(error => {
debugConsole.error(error)
captureException(error)
})
}, [projectId, isRestrictedTokenMember])
// add the project owner and members to the changes users data
const value = useMemo(() => {
const value: ChangesUsers = new Map(changesUsers)
value.set(owner._id, { ...owner, id: owner._id })
for (const member of members) {
value.set(member._id, { ...member, id: member._id })
}
return value
}, [members, owner, changesUsers])
return (
<ChangesUsersContext.Provider value={value}>
{children}
</ChangesUsersContext.Provider>
)
}
export const useChangesUsersContext = () => {
return useContext(ChangesUsersContext)
}

View File

@@ -0,0 +1,197 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import {
Change,
CommentOperation,
EditOperation,
} from '../../../../../types/change'
import RangesTracker from '@overleaf/ranges-tracker'
import { rejectChanges } from '@/features/source-editor/extensions/changes/reject-changes'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import { postJSON } from '@/infrastructure/fetch-json'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { throttle } from 'lodash'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
export type Ranges = {
docId: string
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
}
export const RangesContext = createContext<Ranges | undefined>(undefined)
type RangesActions = {
acceptChanges: (...ids: string[]) => void
rejectChanges: (...ids: string[]) => void
}
const buildRanges = (currentDocument: DocumentContainer | null) => {
const ranges = currentDocument?.ranges
if (!ranges) {
return undefined
}
const dirtyState = ranges.getDirtyState()
ranges.resetDirtyState()
const changed = {
changes: new Set([
...Object.keys(dirtyState.change.added),
...Object.keys(dirtyState.change.moved),
...Object.keys(dirtyState.change.removed),
]),
comments: new Set([
...Object.keys(dirtyState.comment.added),
...Object.keys(dirtyState.comment.moved),
...Object.keys(dirtyState.comment.removed),
]),
}
return {
changes:
changed.changes.size > 0
? ranges.changes.map(change =>
changed.changes.has(change.id) ? { ...change } : change
)
: ranges.changes,
comments:
changed.comments.size > 0
? ranges.comments.map(comment =>
changed.comments.has(comment.id) ? { ...comment } : comment
)
: ranges.comments,
docId: currentDocument.doc_id,
}
}
const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
export const RangesProvider: FC = ({ children }) => {
const view = useCodeMirrorViewContext()
const { projectId } = useIdeReactContext()
const { currentDocument } = useEditorManagerContext()
const { socket } = useConnectionContext()
const [ranges, setRanges] = useState<Ranges | undefined>(() =>
buildRanges(currentDocument)
)
// rebuild the ranges when the current doc changes
useEffect(() => {
setRanges(buildRanges(currentDocument))
}, [currentDocument])
useEffect(() => {
if (currentDocument) {
const listener = throttle(
() => {
window.setTimeout(() => {
setRanges(buildRanges(currentDocument))
})
},
500,
{ leading: true, trailing: true }
)
// currentDocument.on('ranges:clear.cm6', listener)
currentDocument.on('ranges:redraw.cm6', listener)
currentDocument.on('ranges:dirty.cm6', listener)
return () => {
// currentDocument.off('ranges:clear.cm6')
currentDocument.off('ranges:redraw.cm6')
currentDocument.off('ranges:dirty.cm6')
}
}
}, [currentDocument])
// TODO: move this into DocumentContainer?
useEffect(() => {
if (currentDocument) {
const regenerateTrackChangesId = (doc: DocumentContainer) => {
if (doc.ranges) {
const inflight = doc.ranges.getIdSeed()
const pending = RangesTracker.generateIdSeed()
doc.ranges.setIdSeed(pending)
doc.setTrackChangesIdSeeds({ pending, inflight })
}
}
currentDocument.on('flipped_pending_to_inflight', () =>
regenerateTrackChangesId(currentDocument)
)
regenerateTrackChangesId(currentDocument)
return () => {
currentDocument.off('flipped_pending_to_inflight')
}
}
}, [currentDocument])
useSocketListener(
socket,
'accept-changes',
useCallback(
(docId: string, entryIds: string[]) => {
if (currentDocument?.ranges) {
if (docId === currentDocument.doc_id) {
currentDocument.ranges.removeChangeIds(entryIds)
setRanges(buildRanges(currentDocument))
}
}
},
[currentDocument]
)
)
const actions = useMemo(
() => ({
async acceptChanges(...ids: string[]) {
if (currentDocument?.ranges) {
const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
await postJSON(url, { body: { change_ids: ids } })
currentDocument.ranges.removeChangeIds(ids)
setRanges(buildRanges(currentDocument))
}
},
rejectChanges(...ids: string[]) {
if (currentDocument?.ranges) {
view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids))
}
},
}),
[currentDocument, projectId, view]
)
return (
<RangesActionsContext.Provider value={actions}>
<RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
</RangesActionsContext.Provider>
)
}
export const useRangesContext = () => {
return useContext(RangesContext)
}
export const useRangesActionsContext = () => {
const context = useContext(RangesActionsContext)
if (!context) {
throw new Error(
'useRangesActionsContext is only available inside RangesProvider'
)
}
return context
}

View File

@@ -0,0 +1,20 @@
import { FC } from 'react'
import { RangesProvider } from './ranges-context'
import { ChangesUsersProvider } from './changes-users-context'
import { TrackChangesStateProvider } from './track-changes-state-context'
import { ThreadsProvider } from './threads-context'
import { ReviewPanelViewProvider } from './review-panel-view-context'
export const ReviewPanelProviders: FC = ({ children }) => {
return (
<ReviewPanelViewProvider>
<ChangesUsersProvider>
<TrackChangesStateProvider>
<ThreadsProvider>
<RangesProvider>{children}</RangesProvider>
</ThreadsProvider>
</TrackChangesStateProvider>
</ChangesUsersProvider>
</ReviewPanelViewProvider>
)
}

View File

@@ -0,0 +1,54 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useContext,
useMemo,
useState,
} from 'react'
export type View = 'cur_file' | 'overview'
export const ReviewPanelViewContext = createContext<View>('cur_file')
type ViewActions = {
setView: Dispatch<SetStateAction<View>>
}
const ReviewPanelViewActionsContext = createContext<ViewActions | undefined>(
undefined
)
export const ReviewPanelViewProvider: FC = ({ children }) => {
const [view, setView] = useState<View>('cur_file')
const actions = useMemo(
() => ({
setView,
}),
[setView]
)
return (
<ReviewPanelViewActionsContext.Provider value={actions}>
<ReviewPanelViewContext.Provider value={view}>
{children}
</ReviewPanelViewContext.Provider>
</ReviewPanelViewActionsContext.Provider>
)
}
export const useReviewPanelViewContext = () => {
return useContext(ReviewPanelViewContext)
}
export const useReviewPanelViewActionsContext = () => {
const context = useContext(ReviewPanelViewActionsContext)
if (!context) {
throw new Error(
'useViewActionsContext is only available inside ViewProvider'
)
}
return context
}

View File

@@ -0,0 +1,334 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import {
CommentId,
ReviewPanelCommentThreadMessage,
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 { UserId } from '../../../../../types/user'
import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
import RangesTracker from '@overleaf/ranges-tracker'
import { CommentOperation } from '../../../../../types/change'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type Threads = Record<ThreadId, ReviewPanelCommentThread>
export const ThreadsContext = createContext<Threads | undefined>(undefined)
type ThreadsActions = {
addComment: (pos: number, text: string, content: string) => Promise<void>
resolveThread: (threadId: ThreadId) => Promise<void>
reopenThread: (threadId: ThreadId) => Promise<void>
deleteThread: (threadId: ThreadId) => Promise<void>
addMessage: (threadId: ThreadId, content: string) => Promise<void>
editMessage: (
threadId: ThreadId,
commentId: CommentId,
content: string
) => Promise<void>
deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
deleteOwnMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
}
const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
undefined
)
export const ThreadsProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { currentDocument } = useEditorManagerContext()
const { isRestrictedTokenMember } = useEditorContext()
// const [error, setError] = useState<Error>()
const [data, setData] = useState<Threads>()
// load the initial threads data
useEffect(() => {
if (isRestrictedTokenMember) {
return
}
const abortController = new AbortController()
getJSON(`/project/${projectId}/threads`, {
signal: abortController.signal,
})
.then(data => {
setData(data)
})
.catch(error => {
debugConsole.error(error)
captureException(error)
// setError(error)
})
}, [projectId, isRestrictedTokenMember])
const { socket } = useConnectionContext()
useSocketListener(
socket,
'new-comment',
useCallback(
(
threadId: ThreadId,
comment: ReviewPanelCommentThreadMessage & { timestamp: number }
) => {
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
}
})
}, [])
)
useSocketListener(
socket,
'new-comment-threads',
useCallback(threads => {
setData(prevState => {
const newThreads = { ...prevState }
for (const threadId of Object.keys(threads)) {
const thread = threads[threadId]
const newThreadData: ReviewPanelCommentThread = {
messages: [],
resolved: thread.resolved,
resolved_at: thread.resolved_at,
resolved_by_user_id: thread.resolved_by_user_id,
resolved_by_user: thread.resolved_by_user,
}
for (const message of thread.messages) {
newThreadData.messages.push({
...message,
timestamp: new Date(message.timestamp),
})
}
newThreads[threadId as ThreadId] = newThreadData
}
return newThreads
})
}, [])
)
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,
}
currentDocument?.submitOp(op)
},
async resolveThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/resolve`
)
},
async reopenThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/reopen`
)
},
async deleteThread(threadId: string) {
await deleteJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}`
)
currentDocument?.ranges?.removeCommentId(threadId)
},
async addMessage(threadId: ThreadId, content: string) {
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
},
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}`
)
},
async deleteOwnMessage(threadId: ThreadId, commentId: CommentId) {
await deleteJSON(
`/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
)
},
}),
[currentDocument, projectId]
)
return (
<ThreadsActionsContext.Provider value={actions}>
<ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
</ThreadsActionsContext.Provider>
)
}
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
}

View File

@@ -0,0 +1,193 @@
import { UserId } from '../../../../../types/user'
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useUserContext } from '@/shared/context/user-context'
import { postJSON } from '@/infrastructure/fetch-json'
import useEventListener from '@/shared/hooks/use-event-listener'
import { ProjectContextValue } from '@/shared/context/types/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import getMeta from '@/utils/meta'
export type TrackChangesState = {
onForEveryone: boolean
onForGuests: boolean
onForMembers: Record<UserId, boolean | undefined>
}
export const TrackChangesStateContext = createContext<
TrackChangesState | undefined
>(undefined)
type SaveTrackChangesRequestBody = {
on?: boolean
on_for?: Record<UserId, boolean | undefined>
on_for_guests?: boolean
}
type TrackChangesStateActions = {
saveTrackChanges: (trackChangesBody: SaveTrackChangesRequestBody) => void
saveTrackChangesForCurrentUser: (trackChanges: boolean) => void
}
const TrackChangesStateActionsContext = createContext<
TrackChangesStateActions | undefined
>(undefined)
export const TrackChangesStateProvider: FC = ({ children }) => {
const permissions = usePermissionsContext()
const { socket } = useConnectionContext()
const project = useProjectContext()
const user = useUserContext()
const { setWantTrackChanges } = useEditorManagerContext()
// TODO: update project.trackChangesState instead?
const [trackChangesValue, setTrackChangesValue] = useState<
ProjectContextValue['trackChangesState']
>(project.trackChangesState ?? false)
useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
useEffect(() => {
setWantTrackChanges(
trackChangesValue === true ||
(trackChangesValue !== false &&
trackChangesValue[user.id ?? '__guests__'])
)
}, [setWantTrackChanges, trackChangesValue, user.id])
const trackChangesIsObject =
trackChangesValue !== true && trackChangesValue !== false
const onForEveryone = trackChangesValue === true
const onForGuests =
onForEveryone ||
(trackChangesIsObject && trackChangesValue.__guests__ === true)
const onForMembers = useMemo(() => {
const onForMembers: Record<UserId, boolean | undefined> = {}
if (trackChangesIsObject) {
for (const key of Object.keys(trackChangesValue)) {
if (key !== '__guests__') {
onForMembers[key as UserId] = trackChangesValue[key as UserId]
}
}
}
return onForMembers
}, [trackChangesIsObject, trackChangesValue])
const saveTrackChanges = useCallback(
async (trackChangesBody: SaveTrackChangesRequestBody) => {
postJSON(`/project/${project._id}/track_changes`, {
body: trackChangesBody,
})
},
[project._id]
)
const saveTrackChangesForCurrentUser = useCallback(
async (trackChanges: boolean) => {
if (user.id) {
if (getMeta('ol-isReviewerRoleEnabled')) {
saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: trackChanges,
},
})
} else {
saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: trackChanges,
},
on_for_guests: onForGuests,
})
}
}
},
[onForMembers, onForGuests, user.id, saveTrackChanges]
)
const actions = useMemo(
() => ({
saveTrackChanges,
saveTrackChangesForCurrentUser,
}),
[saveTrackChanges, saveTrackChangesForCurrentUser]
)
useEventListener(
'toggle-track-changes',
useCallback(() => {
if (
user.id &&
project.features.trackChanges &&
permissions.write &&
!onForEveryone
) {
const value = onForMembers[user.id]
if (getMeta('ol-isReviewerRoleEnabled')) {
actions.saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: !value,
},
})
} else {
actions.saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: !value,
},
on_for_guests: onForGuests,
})
}
}
}, [
actions,
onForMembers,
onForGuests,
onForEveryone,
permissions.write,
project.features.trackChanges,
user.id,
])
)
const value = useMemo(
() => ({ onForEveryone, onForGuests, onForMembers }),
[onForEveryone, onForGuests, onForMembers]
)
return (
<TrackChangesStateActionsContext.Provider value={actions}>
<TrackChangesStateContext.Provider value={value}>
{children}
</TrackChangesStateContext.Provider>
</TrackChangesStateActionsContext.Provider>
)
}
export const useTrackChangesStateContext = () => {
return useContext(TrackChangesStateContext)
}
export const useTrackChangesStateActionsContext = () => {
const context = useContext(TrackChangesStateActionsContext)
if (!context) {
throw new Error(
'useTrackChangesStateActionsContext is only available inside TrackChangesStateProvider'
)
}
return context
}