Move project context out of scope value store (#26615)

* Refactor project context to not use scope store

* Fix Cypress tests for project context changes

* Fix frontend React Testing Library tests for project context changes

* Remove redundant code

* Fix some project types in tests

* Remove unused import and fix a type

* Throw an error if updating the project in the project context before joining the project

* Fix some review panel tests

* Remove unused imports

GitOrigin-RevId: 2f0c928b651f387aa980c29aef7d1ba0649790a7
This commit is contained in:
Tim Down
2025-07-10 08:06:31 +00:00
committed by Copybot
parent 9237d8227b
commit 905cc5d45f
79 changed files with 806 additions and 703 deletions
@@ -200,7 +200,7 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
clientId.current = chatClientIdGenerator.generate()
}
const user = useUserContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext()
const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext()
@@ -13,13 +13,9 @@ const EditorCloneProjectModalWrapper = React.memo(
handleHide: () => void
openProject: ({ project_id }: { project_id: string }) => void
}) {
const {
_id: projectId,
name: projectName,
tags: projectTags,
} = useProjectContext()
const { project, tags: projectTags } = useProjectContext()
if (!projectName) {
if (!project) {
// wait for useProjectContext
return null
} else {
@@ -28,8 +24,8 @@ const EditorCloneProjectModalWrapper = React.memo(
handleHide={handleHide}
show={show}
handleAfterCloned={openProject}
projectId={projectId}
projectName={projectName}
projectId={project._id}
projectName={project.name}
projectTags={projectTags}
/>
)
@@ -9,7 +9,7 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function DownloadPDF() {
const { t } = useTranslation()
const { pdfDownloadUrl, pdfUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-pdf-button-click', {
@@ -6,7 +6,7 @@ import MaterialIcon from '@/shared/components/material-icon'
export default function DownloadSource() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-zip-button-click', {
@@ -1,40 +1,38 @@
import { useCallback, useEffect } from 'react'
import { useIdeContext } from '../../../shared/context/ide-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
import type { ProjectSettings } from '../utils/api'
export default function useProjectWideSettingsSocketListener() {
const { socket } = useIdeContext()
const [project, setProject] = useScopeValue<ProjectSettings | undefined>(
'project'
)
const { project, updateProject } = useProjectContext()
const setCompiler = useCallback(
(compiler: ProjectSettings['compiler']) => {
if (project) {
setProject({ ...project, compiler })
updateProject({ compiler })
}
},
[project, setProject]
[project, updateProject]
)
const setImageName = useCallback(
(imageName: ProjectSettings['imageName']) => {
if (project) {
setProject({ ...project, imageName })
updateProject({ imageName })
}
},
[project, setProject]
[project, updateProject]
)
const setSpellCheckLanguage = useCallback(
(spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
if (project) {
setProject({ ...project, spellCheckLanguage })
updateProject({ spellCheckLanguage })
}
},
[project, setProject]
[project, updateProject]
)
useEffect(() => {
@@ -1,14 +1,14 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
import useRootDocId from './use-root-doc-id'
import useSaveProjectSettings from './use-save-project-settings'
import useSetSpellCheckLanguage from './use-set-spell-check-language'
import { debugConsole } from '@/utils/debugging'
import { useProjectContext } from '@/shared/context/project-context'
export default function useProjectWideSettings() {
// The value will be undefined on mount
const [project] = useScopeValue<ProjectSettings | undefined>('project')
const { project } = useProjectContext()
const saveProjectSettings = useSaveProjectSettings()
const setCompiler = useCallback(
@@ -1,12 +1,12 @@
import { useCallback } from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
import type { ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useRootDocId() {
const [rootDocId] =
useScopeValue<ProjectSettings['rootDocId']>('project.rootDocId')
const { project } = useProjectContext()
const rootDocId = project?.rootDocId
const { permissionsLevel } = useIdeReactContext()
const saveProjectSettings = useSaveProjectSettings()
@@ -1,25 +1,21 @@
import { type ProjectSettings, saveProjectSettings } from '../utils/api'
import { useProjectContext } from '../../../shared/context/project-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
export default function useSaveProjectSettings() {
// projectSettings value will be undefined on mount
const [projectSettings, setProjectSettings] = useScopeValue<
ProjectSettings | undefined
>('project')
const { _id: projectId } = useProjectContext()
const { projectId, project, updateProject } = useProjectContext()
return async (
key: keyof ProjectSettings,
newSetting: ProjectSettings[keyof ProjectSettings]
) => {
if (projectSettings) {
const currentSetting = projectSettings[key]
if (project) {
const currentSetting = project[key]
if (currentSetting !== newSetting) {
await saveProjectSettings(projectId, {
[key]: newSetting,
})
setProjectSettings({ ...projectSettings, [key]: newSetting })
updateProject({ [key]: newSetting })
}
}
}
@@ -1,12 +1,11 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
import { type ProjectSettings, saveUserSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useSetSpellCheckLanguage() {
const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<
ProjectSettings['spellCheckLanguage']
>('project.spellCheckLanguage')
const { project } = useProjectContext()
const spellCheckLanguage = project?.spellCheckLanguage
const saveProjectSettings = useSaveProjectSettings()
return useCallback(
@@ -16,10 +15,8 @@ export default function useSetSpellCheckLanguage() {
newSpellCheckLanguage !== spellCheckLanguage
if (allowUpdate) {
setSpellCheckLanguage(newSpellCheckLanguage)
// Save project settings is created from hooks because it will save the value on
// both server-side and client-side (angular scope)
// both server-side and client-side (project context)
saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage)
// For user settings, we only need to save it on server-side,
@@ -27,6 +24,6 @@ export default function useSetSpellCheckLanguage() {
saveUserSettings('spellCheckLanguage', newSpellCheckLanguage)
}
},
[setSpellCheckLanguage, spellCheckLanguage, saveProjectSettings]
[spellCheckLanguage, saveProjectSettings]
)
}
@@ -4,7 +4,7 @@ import { postJSON } from '../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { UserSettings } from '../../../../../types/user-settings'
export type ProjectSettings = {
export interface ProjectSettings {
compiler: ProjectCompiler
imageName: string
rootDocId: string
@@ -96,7 +96,7 @@ export function FolderUploadConflicts({
setError: (error: string) => void
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
// Don't allow overwriting files with a folder
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
@@ -191,7 +191,7 @@ function SelectProject({
setSelectedProject,
}: SelectProjectProps) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { data, error, loading } = useUserProjects()
@@ -28,7 +28,7 @@ export default function FileTreeUploadDoc() {
const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
useFileTreeActionable()
const { fileTreeData } = useFileTreeData()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [error, setError] = useState<string>()
@@ -6,7 +6,7 @@ import { useLocation } from '../../../../shared/hooks/use-location'
// handle "not-logged-in" errors by redirecting to the login page
export default function RedirectToLogin() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [secondsToRedirect, setSecondsToRedirect] = useState(10)
const location = useLocation()
@@ -25,14 +25,15 @@ function FileTreeItemMenuItems() {
selectedFileName,
} = useFileTreeActionable()
const { owner } = useProjectContext()
const { project } = useProjectContext()
const projectOwner = project?.owner?._id
const downloadWithAnalytics = useCallback(() => {
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
if (selectedFileName?.endsWith('.bib')) {
eventTracking.sendMB('download-bib-file', { projectOwner: owner._id })
eventTracking.sendMB('download-bib-file', { projectOwner })
}
}, [selectedFileName, owner])
}, [selectedFileName, projectOwner])
const createWithAnalytics = useCallback(() => {
eventTracking.sendMB('new-file-click', { location: 'file-menu' })
@@ -40,7 +40,7 @@ const FileTreeRoot = React.memo<{
}) {
const [fileTreeContainer, setFileTreeContainer] =
useState<HTMLDivElement | null>(null)
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { fileTreeData } = useFileTreeData()
const isReady = Boolean(projectId && fileTreeData)
const newEditor = useIsNewEditorEnabled()
@@ -222,7 +222,7 @@ function fileTreeActionableReducer(state: State, action: Action) {
export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { fileTreeReadOnly } = useFileTreeData()
const { indexAllReferences } = useReferencesContext()
const { write } = usePermissionsContext()
@@ -128,7 +128,8 @@ export const FileTreeSelectableProvider: FC<
onSelect: (value: FindResult[]) => void
}>
> = ({ onSelect, children }) => {
const { _id: projectId, rootDocId } = useProjectContext()
const { projectId, project } = useProjectContext()
const rootDocId = project?.rootDocId
const [initialSelectedEntityId] = usePersistedState(
`doc.open_id.${projectId}`,
@@ -49,7 +49,7 @@ type FileViewHeaderProps = {
}
export default function FileViewHeader({ file }: FileViewHeaderProps) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { fileTreeReadOnly } = useFileTreeData()
const { t } = useTranslation()
@@ -11,7 +11,7 @@ export default function FileViewImage({
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
return (
<img
src={fileUrl(projectId, file.id, file.hash)}
@@ -31,7 +31,7 @@ export default function FileViewRefreshButton({
setRefreshError,
file,
}: FileViewRefreshButtonProps) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [refreshing, setRefreshing] = useState(false)
const isMountedRef = useIsMounted()
@@ -16,7 +16,7 @@ export default function FileViewText({
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [textPreview, setTextPreview] = useState('')
const [shouldShowDots, setShouldShowDots] = useState(false)
@@ -78,13 +78,10 @@ const updatesInfoInitialState: HistoryContextValue['updatesInfo'] = {
function useHistory() {
const { view } = useLayoutContext()
const user = useUserContext()
const project = useProjectContext()
const { projectId, project, features } = useProjectContext()
const userId = user.id
const projectId = project._id
const projectOwnerId = project.owner?._id
const userHasFullFeature = Boolean(
project.features?.versioning || user.isAdmin
)
const projectOwnerId = project?.owner?._id
const userHasFullFeature = Boolean(features.versioning || user.isAdmin)
const currentUserIsOwner = projectOwnerId === userId
const [selection, setSelection] = useState<Selection>(selectionInitialState)
@@ -39,7 +39,9 @@ const FileTreeOpenContext = createContext<
export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { rootDocId, owner } = useProjectContext()
const { project } = useProjectContext()
const rootDocId = project?.rootDocId
const projectOwner = project?.owner?._id
const { eventEmitter, projectJoined } = useIdeReactContext()
const { openDocWithId, openInitialDoc } = useEditorManagerContext()
const { currentDocumentId } = useEditorOpenDocContext()
@@ -81,7 +83,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
openDocWithId(selected.entity._id, { keepCurrentView: true })
if (selected.entity.name.endsWith('.bib')) {
sendMB('open-bib-file', {
projectOwner: owner._id,
projectOwner,
isSampleFile: selected.entity.name === 'sample.bib',
linkedFileProvider: null,
})
@@ -96,7 +98,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
if (openFile) {
if (selected?.entity?.name?.endsWith('.bib')) {
sendMB('open-bib-file', {
projectOwner: owner._id,
projectOwner,
isSampleFile: false,
linkedFileProvider: (selected.entity as FileRef).linkedFileData
?.provider,
@@ -105,7 +107,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
window.dispatchEvent(new CustomEvent('file-view:file-opened'))
}
},
[fileTreeReady, setOpenFile, openDocWithId, owner]
[fileTreeReady, setOpenFile, openDocWithId, projectOwner]
)
const handleFileTreeDelete = useCallback(
@@ -19,6 +19,8 @@ import { postJSON } from '@/infrastructure/fetch-json'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import getMeta from '@/utils/meta'
import { type PermissionsLevel } from '@/features/ide-react/types/permissions'
import { useProjectContext } from '@/shared/context/project-context'
import { ProjectMetadata } from '@/shared/context/types/project-metadata'
const LOADED_AT = new Date()
@@ -44,10 +46,6 @@ function populateIdeReactScope(store: ReactScopeValueStore) {
store.set('settings', {})
}
function populateProjectScope(store: ReactScopeValueStore) {
store.allowNonExistentPath('project', true)
}
function populatePdfScope(store: ReactScopeValueStore) {
store.allowNonExistentPath('pdf', true)
}
@@ -61,7 +59,6 @@ export function createReactScopeValueStore() {
// initialization code together with the context and would only populate
// necessary values in the store, but this is simpler for now
populateIdeReactScope(scopeStore)
populateProjectScope(scopeStore)
populatePdfScope(scopeStore)
scopeStore.allowNonExistentPath('hasLintingError')
@@ -94,6 +91,8 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [projectJoined, setProjectJoined] = useState(false)
const { socket, getSocketDebuggingInfo } = useConnectionContext()
const { joinProject, project } = useProjectContext()
const spellCheckLanguage = project?.spellCheckLanguage
const reportError = useCallback(
(error: any, meta?: Record<string, any>) => {
@@ -105,7 +104,7 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
performance_now: performance.now(),
release,
client_load: LOADED_AT,
spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
spellCheckLanguage,
...getSocketDebuggingInfo(),
}
@@ -124,17 +123,28 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
},
})
},
[release, projectId, getSocketDebuggingInfo, scopeStore]
[release, projectId, getSocketDebuggingInfo, spellCheckLanguage]
)
// Populate scope values when joining project, then fire project:joined event
useEffect(() => {
function handleJoinProjectResponse({
project: { rootDoc_id: rootDocId, ..._project },
project: {
rootDoc_id: rootDocId,
publicAccesLevel: publicAccessLevel,
..._project
},
permissionsLevel,
}: JoinProjectPayload) {
const project = { ..._project, rootDocId }
scopeStore.set('project', project)
const project = { ..._project, rootDocId, publicAccessLevel }
// Cast the project from the payload as ProjectMetadata to ensure it has
// the correct type for the context. It must be close enough because the
// data structure hasn't changed and it worked previously. This type
// coercion was previously sidestepped by adding the project to the scope
// store, which does not enforce types.
joinProject(project as unknown as ProjectMetadata)
setPermissionsLevel(permissionsLevel)
// Make watchers update immediately
scopeStore.flushUpdates()
@@ -142,21 +152,12 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
setProjectJoined(true)
}
function handleMainBibliographyDocUpdated(payload: string) {
scopeStore.set('project.mainBibliographyDoc_id', payload)
}
socket.on('joinProjectResponse', handleJoinProjectResponse)
socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated)
return () => {
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
socket.removeListener(
'mainBibliographyDocUpdated',
handleMainBibliographyDocUpdated
)
}
}, [socket, eventEmitter, scopeStore])
}, [socket, eventEmitter, scopeStore, joinProject])
const ide = useMemo(() => {
return {
@@ -124,7 +124,7 @@ export const OutlineProvider: FC<React.PropsWithChildren> = ({ children }) => {
[openDocName]
)
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const storageKey = `file_outline.expanded.${projectId}`
const [outlineExpanded, setOutlineExpanded] = useState(
@@ -98,7 +98,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
const { permissionsLevel } = useIdeReactContext()
const hasViewerPermissions = useViewerPermissions()
const anonymous = getMeta('ol-anonymous')
const project = useProjectContext()
const { features } = useProjectContext()
useEffect(() => {
let activePermissionsMap
@@ -106,7 +106,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
activePermissionsMap = linkSharingWarningPermissionsMap
} else if (anonymous) {
activePermissionsMap = anonymousPermissionsMap
} else if (!project.features.trackChanges) {
} else if (!features.trackChanges) {
activePermissionsMap = noTrackChangesPermissionsMap
} else {
activePermissionsMap = permissionsMap
@@ -117,7 +117,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
permissionsLevel,
setPermissions,
hasViewerPermissions,
project.features.trackChanges,
features.trackChanges,
])
useEffect(() => {
@@ -75,10 +75,10 @@ export const ReactContextRoot: FC<
<Providers.SplitTestProvider>
<Providers.ModalsContextProvider>
<Providers.ConnectionProvider>
<Providers.IdeReactProvider>
<Providers.UserProvider>
<Providers.UserSettingsProvider>
<Providers.ProjectProvider>
<Providers.ProjectProvider>
<Providers.IdeReactProvider>
<Providers.UserProvider>
<Providers.UserSettingsProvider>
<Providers.SnapshotProvider>
<Providers.DetachProvider>
<Providers.EditorPropertiesProvider>
@@ -128,10 +128,10 @@ export const ReactContextRoot: FC<
</Providers.EditorPropertiesProvider>
</Providers.DetachProvider>
</Providers.SnapshotProvider>
</Providers.ProjectProvider>
</Providers.UserSettingsProvider>
</Providers.UserProvider>
</Providers.IdeReactProvider>
</Providers.UserSettingsProvider>
</Providers.UserProvider>
</Providers.IdeReactProvider>
</Providers.ProjectProvider>
</Providers.ConnectionProvider>
</Providers.ModalsContextProvider>
</Providers.SplitTestProvider>
@@ -55,7 +55,7 @@ export const SnapshotContext = createContext<
>(undefined)
export const SnapshotProvider: FC<React.PropsWithChildren> = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [snapshotLoadingState, setSnapshotLoadingState] =
useState<SnapshotLoadingState>('')
const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId))
@@ -4,7 +4,7 @@ import {
listProjectInvites,
listProjectMembers,
} from '@/features/share-project-modal/utils/api'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
@@ -16,11 +16,9 @@ import { useLocation } from '@/shared/hooks/use-location'
function useSocketListeners() {
const { t } = useTranslation()
const { socket } = useConnectionContext()
const { permissionsLevel, projectId } = useIdeReactContext()
const { showGenericMessageModal } = useModalsContext()
const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel')
const [, setProjectMembers] = useScopeValue('project.members')
const [, setProjectInvites] = useScopeValue('project.invites')
const { permissionsLevel } = useIdeReactContext()
const { projectId, updateProject } = useProjectContext()
const location = useLocation()
useSocketListener(
@@ -51,10 +49,10 @@ function useSocketListeners() {
useCallback(
(data: { newAccessLevel?: PublicAccessLevel }) => {
if (data.newAccessLevel) {
setPublicAccessLevel(data.newAccessLevel)
updateProject({ publicAccessLevel: data.newAccessLevel })
}
},
[setPublicAccessLevel]
[updateProject]
)
)
@@ -65,13 +63,13 @@ function useSocketListeners() {
listProjectMembers(projectId)
.then(({ members }) => {
if (members) {
setProjectMembers(members)
updateProject({ members })
}
})
.catch(err => {
debugConsole.error('Error fetching members for project', err)
})
}, [projectId, setProjectMembers])
}, [projectId, updateProject])
)
useSocketListener(
@@ -83,7 +81,7 @@ function useSocketListeners() {
listProjectMembers(projectId)
.then(({ members }) => {
if (members) {
setProjectMembers(members)
updateProject({ members })
}
})
.catch(err => {
@@ -95,7 +93,7 @@ function useSocketListeners() {
listProjectInvites(projectId)
.then(({ invites }) => {
if (invites) {
setProjectInvites(invites)
updateProject({ invites })
}
})
.catch(err => {
@@ -103,7 +101,29 @@ function useSocketListeners() {
})
}
},
[projectId, setProjectInvites, setProjectMembers, permissionsLevel]
[projectId, updateProject, permissionsLevel]
)
)
useSocketListener(
socket,
'mainBibliographyDocUpdated',
useCallback(
(payload: string) => {
updateProject({ mainBibliographyDocId: payload })
},
[updateProject]
)
)
useSocketListener(
socket,
'projectNameUpdated',
useCallback(
(payload: string) => {
updateProject({ name: payload })
},
[updateProject]
)
)
}
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
export const DownloadProjectZip = () => {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const sendDownloadEvent = useCallback(() => {
sendMB('download-zip-button-click', {
projectId,
@@ -44,7 +44,7 @@ export const DownloadProjectZip = () => {
export const DownloadProjectPDF = () => {
const { t } = useTranslation()
const { pdfDownloadUrl, pdfUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const sendDownloadEvent = useCallback(() => {
sendMB('download-pdf-button-click', {
projectId,
@@ -9,7 +9,7 @@ import MaterialIcon from '@/shared/components/material-icon'
function PdfHybridDownloadButton() {
const { pdfDownloadUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { t } = useTranslation()
const description = pdfDownloadUrl
@@ -23,7 +23,7 @@ type PdfJsViewerProps = {
}
function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { setError, firstRenderDone, highlights, position, setPosition } =
useCompileContext()
@@ -28,7 +28,8 @@ export default function useSynctex(): {
syncToCodeInFlight: boolean
canSyncToPdf: boolean
} {
const { _id: projectId, rootDocId } = useProjectContext()
const { projectId, project } = useProjectContext()
const rootDocId = project?.rootDocId
const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
useCompileContext()
@@ -55,7 +55,7 @@ function ReviewModeSwitcher() {
const mode = useCurrentMode()
const { permissionsLevel } = useIdeReactContext()
const { write, trackedWrite } = usePermissionsContext()
const project = useProjectContext()
const { features } = useProjectContext()
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
const showViewOption = permissionsLevel === 'readOnly'
const view = useCodeMirrorViewContext()
@@ -100,7 +100,7 @@ function ReviewModeSwitcher() {
view.focus()
return
}
if (!project.features.trackChanges) {
if (!features.trackChanges) {
setShowUpgradeModal(true)
} else {
sendMB('editing-mode-change', {
@@ -212,7 +212,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
})
const user = useUserContext()
const project = useProjectContext()
const { features } = useProjectContext()
const { reviewPanelOpen } = useLayoutContext()
const { inactiveTutorials } = useEditorContext()
@@ -222,7 +222,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
const canShowReviewModePromo =
reviewPanelOpen &&
currentMode !== 'review' &&
project.features.trackChanges &&
features.trackChanges &&
user.signUpDate &&
user.signUpDate < '2025-03-15' &&
!hasCompletedReviewModeTutorial
@@ -26,7 +26,7 @@ function UpgradeTrackChangesModal({
setShow,
}: UpgradeTrackChangesModalProps) {
const { t } = useTranslation()
const project = useProjectContext()
const { project } = useProjectContext()
const user = useUserContext()
return (
@@ -62,9 +62,9 @@ function UpgradeTrackChangesModal({
</ul>
</OLCol>
</OLRow>
{project.owner && (
{Boolean(project?.owner) && (
<div className="text-center">
{project.owner._id === user.id ? (
{project?.owner._id === user.id ? (
user.allowedFreeTrial ? (
<OLButton
variant="premium"
@@ -29,7 +29,8 @@ export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
export const ChangesUsersProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { _id: projectId, members, owner } = useProjectContext()
const { projectId, project } = useProjectContext()
const { members, owner } = project || {}
const { isRestrictedTokenMember } = useEditorContext()
const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
@@ -49,6 +50,9 @@ export const ChangesUsersProvider: FC<React.PropsWithChildren> = ({
// add the project owner and members to the changes users data
const value = useMemo(() => {
if (!owner || !members) {
return
}
const value: ChangesUsers = new Map(changesUsers)
value.set(owner._id, { ...owner, id: owner._id })
for (const member of members) {
@@ -49,7 +49,7 @@ const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
)
export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { currentDocument } = useEditorOpenDocContext()
const { isRestrictedTokenMember } = useEditorContext()
@@ -15,7 +15,7 @@ import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-
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 { ProjectMetadata } from '@/shared/context/types/project-metadata'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export type TrackChangesState = {
@@ -48,14 +48,14 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
}) => {
const permissions = usePermissionsContext()
const { socket } = useConnectionContext()
const project = useProjectContext()
const { projectId, project, features } = useProjectContext()
const user = useUserContext()
const { setWantTrackChanges } = useEditorPropertiesContext()
// TODO: update project.trackChangesState instead?
const [trackChangesValue, setTrackChangesValue] = useState<
ProjectContextValue['trackChangesState']
>(project.trackChangesState ?? false)
ProjectMetadata['trackChangesState']
>(project?.trackChangesState ?? false)
useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
@@ -88,11 +88,11 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
const saveTrackChanges = useCallback(
async (trackChangesBody: SaveTrackChangesRequestBody) => {
postJSON(`/project/${project._id}/track_changes`, {
postJSON(`/project/${projectId}/track_changes`, {
body: trackChangesBody,
})
},
[project._id]
[projectId]
)
const saveTrackChangesForCurrentUser = useCallback(
@@ -122,7 +122,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
useCallback(() => {
if (
user.id &&
project.features.trackChanges &&
features.trackChanges &&
permissions.write &&
!onForEveryone
) {
@@ -139,7 +139,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
onForMembers,
onForEveryone,
permissions.write,
project.features.trackChanges,
features.trackChanges,
user.id,
])
)
@@ -23,7 +23,7 @@ const safeParse = (value: string) => {
}
export default function useOverviewFileCollapsed(docId: DocId) {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [collapsedDocs, setCollapsedDocs] = usePersistedState<
Record<DocId, boolean>,
string
@@ -6,7 +6,7 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
export default function useProjectRanges() {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [error, setError] = useState<Error>()
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
const [loading, setLoading] = useState(true)
@@ -23,9 +23,10 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
const { t } = useTranslation()
const { updateProject, setInFlight, setError } = useShareProjectContext()
const { setInFlight, setError } = useShareProjectContext()
const { _id: projectId, members, invites, features } = useProjectContext()
const { projectId, project, features, updateProject } = useProjectContext()
const { members, invites } = project || {}
const currentMemberEmails = useMemo(
() => (members || []).map(member => member.email).sort(),
@@ -82,9 +83,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
let data
try {
const invite = (invites || []).find(
invite => invite.email === normalisedEmail
)
const invite = invites?.find(invite => invite.email === normalisedEmail)
if (invite) {
data = await resendInvite(projectId, invite)
@@ -109,8 +108,8 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
successful_invite: !!data.invite,
users_updated: !!(data.users || data.user),
current_collaborators_amount: members.length,
current_invites_amount: invites.length,
current_collaborators_amount: members?.length || 0,
current_invites_amount: invites?.length || 0,
role,
previousEditorsAmount,
previousReviewersAmount,
@@ -144,15 +143,15 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
setInFlight(false)
} else if (data.invite) {
updateProject({
invites: invites.concat(data.invite),
invites: invites?.concat(data.invite) || [data.invite],
})
} else if (data.users) {
updateProject({
members: members.concat(data.users),
members: members?.concat(data.users) || data.users,
})
} else if (data.user) {
updateProject({
members: members.concat(data.user),
members: members?.concat(data.user) || [data.user],
})
}
@@ -6,7 +6,7 @@ import { removeMemberFromProject, updateMember } from '../utils/api'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { Select } from '@/shared/components/select'
import type { ProjectContextMember } from '@/shared/context/types/project-context'
import type { ProjectMember } from '@/shared/context/types/project-metadata'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { linkSharingEnforcementDate } from '../utils/link-sharing'
import OLButton from '@/features/ui/components/ol/ol-button'
@@ -19,7 +19,7 @@ import { upgradePlan } from '@/main/account-upgrade'
type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
type EditMemberProps = {
member: ProjectContextMember
member: ProjectMember
hasExceededCollaboratorLimit: boolean
hasBeenDowngraded: boolean
canAddCollaborators: boolean
@@ -51,8 +51,9 @@ export default function EditMember({
setPrivileges(member.privileges)
}, [member.privileges])
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, members, invites } = useProjectContext()
const { monitorRequest } = useShareProjectContext()
const { projectId, project, updateProject } = useProjectContext()
const { members, invites } = project || {}
const user = useUserContext()
// Immediately commit this change if it's lower impact (eg. editor > viewer)
@@ -88,14 +89,15 @@ export default function EditMember({
} else if (newPrivileges === 'removeAccess') {
monitorRequest(() => removeMemberFromProject(projectId, member)).then(
() => {
const updatedMembers = members.filter(existing => existing !== member)
const updatedMembers =
members?.filter(existing => existing !== member) || []
updateProject({
members: updatedMembers,
})
sendMB('collaborator-removed', {
project_id: projectId,
current_collaborators_amount: updatedMembers.length,
current_invites_amount: invites.length,
current_invites_amount: invites?.length || 0,
})
}
)
@@ -110,9 +112,10 @@ export default function EditMember({
})
).then(() => {
updateProject({
members: members.map(item =>
item._id === member._id ? { ...item, newPrivileges } : item
),
members:
members?.map(item =>
item._id === member._id ? { ...item, newPrivileges } : item
) || [],
})
})
}
@@ -12,7 +12,8 @@ const EditorOverLimitModal = () => {
const { isProjectOwner } = useEditorContext()
const { permissionsLevel } = useIdeReactContext()
const { members, features, _id: projectId } = useProjectContext()
const { project, features, projectId } = useProjectContext()
const members = project?.members
const handleHide = () => {
setShow(false)
@@ -24,7 +25,12 @@ const EditorOverLimitModal = () => {
useEffect(() => {
const showModalCooldownHours = 24
const hasExceededCollaboratorLimit = () => {
if (isProjectOwner || !features || permissionsLevel === 'readOnly') {
if (
isProjectOwner ||
!features ||
!members ||
permissionsLevel === 'readOnly'
) {
return false
}
@@ -10,13 +10,13 @@ import OLCol from '@/features/ui/components/ol/ol-col'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { ProjectContextMember } from '@/shared/context/types/project-context'
import { ProjectMember } from '@/shared/context/types/project-metadata'
export default function Invite({
invite,
isProjectOwner,
}: {
invite: ProjectContextMember
invite: ProjectMember
isProjectOwner: boolean
}) {
const { t } = useTranslation()
@@ -44,10 +44,10 @@ export default function Invite({
)
}
function ResendInvite({ invite }: { invite: ProjectContextMember }) {
function ResendInvite({ invite }: { invite: ProjectMember }) {
const { t } = useTranslation()
const { monitorRequest, setError, inFlight } = useShareProjectContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
// const buttonRef = useRef(null)
//
@@ -87,23 +87,25 @@ function ResendInvite({ invite }: { invite: ProjectContextMember }) {
)
}
function RevokeInvite({ invite }: { invite: ProjectContextMember }) {
function RevokeInvite({ invite }: { invite: ProjectMember }) {
const { t } = useTranslation()
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, invites, members } = useProjectContext()
const { monitorRequest } = useShareProjectContext()
const { projectId, project, updateProject } = useProjectContext()
const { invites, members } = project || {}
function handleClick(event: React.MouseEvent) {
event.preventDefault()
monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
const updatedInvites = invites.filter(existing => existing !== invite)
const updatedInvites =
invites?.filter(existing => existing !== invite) || []
updateProject({
invites: updatedInvites,
})
sendMB('collaborator-invite-revoked', {
project_id: projectId,
current_invites_amount: updatedInvites.length,
current_collaborators_amount: members.length,
current_collaborators_amount: members?.length || 0,
})
})
}
@@ -33,7 +33,8 @@ export default function LinkSharing() {
const { monitorRequest } = useShareProjectContext()
const { _id: projectId, publicAccessLevel } = useProjectContext()
const { projectId, project } = useProjectContext()
const { publicAccessLevel } = project || {}
// set the access level of a project
const setAccessLevel = useCallback(
@@ -145,7 +146,7 @@ function TokenBasedSharing({
showLinks: boolean
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [tokens, setTokens] = useState<Tokens | null>(null)
@@ -244,7 +245,7 @@ function LegacySharing({
export function ReadOnlyTokenLink() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const [tokens, setTokens] = useState<Tokens | null>(null)
@@ -1,10 +1,10 @@
import { ProjectContextMember } from '@/shared/context/types/project-context'
import { ProjectMember } from '@/shared/context/types/project-metadata'
import { useTranslation } from 'react-i18next'
export default function MemberPrivileges({
privileges,
}: {
privileges: ProjectContextMember['privileges']
privileges: ProjectMember['privileges']
}) {
const { t } = useTranslation()
@@ -6,14 +6,14 @@ import MaterialIcon from '@/shared/components/material-icon'
export default function OwnerInfo() {
const { t } = useTranslation()
const { owner } = useProjectContext()
const { project } = useProjectContext()
return (
<OLRow className="project-member">
<OLCol xs={8}>
<div className="project-member-email-icon">
<MaterialIcon type="person" />
<div className="email-warning">{owner?.email}</div>
<div className="email-warning">{project?.owner.email}</div>
</div>
</OLCol>
<OLCol xs={4} className="text-end">
@@ -7,7 +7,8 @@ import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
export default function SendInvitesNotice() {
const { publicAccessLevel } = useProjectContext()
const { project } = useProjectContext()
const { publicAccessLevel } = project || {}
const { isPendingEditor } = useEditorContext()
const { t } = useTranslation()
@@ -12,7 +12,8 @@ import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import getMeta from '@/utils/meta'
export default function ShareModalBody() {
const { members, invites, features } = useProjectContext()
const { project, features } = useProjectContext()
const { members, invites } = project || {}
const { isProjectOwner } = useEditorContext()
// whether the project has not reached the collaborator limit
@@ -26,12 +27,12 @@ export default function ShareModalBody() {
return true
}
const editorInvites = invites.filter(
invite => invite.privileges !== 'readOnly'
).length
const editorInvites =
invites?.filter(invite => invite.privileges !== 'readOnly').length || 0
return (
members.filter(member => member.privileges !== 'readOnly').length +
(members?.filter(member => member.privileges !== 'readOnly').length ||
0) +
editorInvites <
(features.collaborators ?? 1)
)
@@ -40,11 +41,11 @@ export default function ShareModalBody() {
// determine if some but not all pending editors' permissions have been resolved,
// for moving between warning and info notification states etc.
const somePendingEditorsResolved = useMemo(() => {
return (
members.some(member =>
return Boolean(
members?.some(member =>
['readAndWrite', 'review'].includes(member.privileges)
) &&
members.some(member => member.pendingEditor || member.pendingReviewer)
members?.some(member => member.pendingEditor || member.pendingReviewer)
)
}, [members])
@@ -56,13 +57,14 @@ export default function ShareModalBody() {
if (features.collaborators === -1) {
return false
}
return members.some(
member => member.pendingEditor || member.pendingReviewer
return (
members?.some(member => member.pendingEditor || member.pendingReviewer) ||
false
)
}, [features, isProjectOwner, members])
const hasExceededCollaboratorLimit = useMemo(() => {
if (!isProjectOwner || !features) {
if (!isProjectOwner || !features || !members) {
return false
}
@@ -77,6 +79,9 @@ export default function ShareModalBody() {
}, [features, isProjectOwner, members])
const sortedMembers = useMemo(() => {
if (!members) {
return []
}
return [
...members.filter(member => member.privileges === 'readAndWrite'),
...members.filter(member => member.pendingEditor),
@@ -126,7 +131,7 @@ export default function ShareModalBody() {
)
)}
{invites.map(invite => (
{(invites || []).map(invite => (
<Invite
key={invite._id}
invite={invite}
@@ -9,12 +9,10 @@ import ShareProjectModalContent from './share-project-modal-content'
import { useProjectContext } from '@/shared/context/project-context'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
import { useEditorContext } from '@/shared/context/editor-context'
import customLocalStorage from '@/infrastructure/local-storage'
type ShareProjectContextValue = {
updateProject: (project: ProjectContextUpdateValue) => void
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
inFlight: boolean
setInFlight: React.Dispatch<
@@ -61,7 +59,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
useState<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const project = useProjectContext()
const { project, projectId } = useProjectContext()
const { isProjectOwner } = useEditorContext()
const { splitTestVariants } = useSplitTestContext()
@@ -70,7 +68,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
// is over collaborator limit or has pending editors (once every 24 hours)
useEffect(() => {
const hasExceededCollaboratorLimit = () => {
if (!isProjectOwner || !project.features) {
if (!isProjectOwner || !project || !project.features) {
return false
}
@@ -88,7 +86,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
}
if (hasExceededCollaboratorLimit()) {
const localStorageKey = `last-shown-share-modal.${project._id}`
const localStorageKey = `last-shown-share-modal.${projectId}`
const lastShownShareModalTime =
customLocalStorage.getItem(localStorageKey)
if (
@@ -99,17 +97,17 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
customLocalStorage.setItem(localStorageKey, Date.now())
}
}
}, [project, isProjectOwner, handleOpen])
}, [project, isProjectOwner, handleOpen, projectId])
// send tracking event when the modal is opened
useEffect(() => {
if (show) {
sendMB('share-modal-opened', {
splitTestVariant: splitTestVariants['null-test-share-modal'],
project_id: project._id,
project_id: projectId,
})
}
}, [splitTestVariants, project._id, show])
}, [splitTestVariants, projectId, show])
// reset error when the modal is opened
useEffect(() => {
@@ -147,12 +145,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
return promise
}, [])
// merge the new data with the old project data
const updateProject = useCallback(
(data: ProjectContextUpdateValue) => Object.assign(project, data),
[project]
)
if (!project) {
return null
}
@@ -160,7 +152,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
return (
<ShareProjectContext.Provider
value={{
updateProject,
monitorRequest,
inFlight,
setInFlight,
@@ -12,13 +12,13 @@ import OLModal, {
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import { Spinner } from 'react-bootstrap'
import { ProjectContextMember } from '@/shared/context/types/project-context'
import { ProjectMember } from '@/shared/context/types/project-metadata'
export default function TransferOwnershipModal({
member,
cancel,
}: {
member: ProjectContextMember
member: ProjectMember
cancel: () => void
}) {
const { t } = useTranslation()
@@ -27,7 +27,7 @@ export default function TransferOwnershipModal({
const [error, setError] = useState(false)
const location = useLocation()
const { _id: projectId, name: projectName } = useProjectContext()
const { projectId, name: projectName } = useProjectContext()
function confirm() {
setError(false)
@@ -2,13 +2,9 @@ import MemberPrivileges from './member-privileges'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
import { ProjectContextMember } from '@/shared/context/types/project-context'
import { ProjectMember } from '@/shared/context/types/project-metadata'
export default function ViewMember({
member,
}: {
member: ProjectContextMember
}) {
export default function ViewMember({ member }: { member: ProjectMember }) {
return (
<OLRow className="project-member">
<OLCol xs={8}>
@@ -12,7 +12,8 @@ const ViewOnlyAccessModal = () => {
const { isProjectOwner, isPendingEditor } = useEditorContext()
const { permissionsLevel } = useIdeReactContext()
const { members, features, _id: projectId } = useProjectContext()
const { features, projectId, project } = useProjectContext()
const members = project?.members
const handleHide = () => {
setShow(false)
@@ -34,7 +34,7 @@ export const FigureModalOtherProjectSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { loading: projectsLoading, data: projects, error } = useUserProjects()
const [selectedProject, setSelectedProject] = useState<null | Project>(null)
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
@@ -34,7 +34,7 @@ export const FigureModalUploadFileSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch, pastedImageData } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File | null>(null)
const [nameDirty, setNameDirty] = useState<boolean>(false)
@@ -46,7 +46,7 @@ export const FigureModalUrlSource: FC = () => {
const [url, setUrl] = useState<string>('')
const [nameDirty, setNameDirty] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File>(rootFile)
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
import useEventListener from '../../../shared/hooks/use-event-listener'
import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
@@ -55,6 +54,7 @@ import { GotoOffsetOptions } from '@/features/ide-react/context/editor-manager-c
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useProjectContext } from '@/shared/context/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { SearchQuery } from '@codemirror/search'
@@ -92,17 +92,13 @@ function useCodeMirrorScope(view: EditorView) {
const { onlineUserCursorHighlights } = useOnlineUsersContext()
let [spellCheckLanguage] = useScopeValue<string>('project.spellCheckLanguage')
const { project, features: projectFeatures } = useProjectContext()
let spellCheckLanguage = project?.spellCheckLanguage || ''
// spell check is off when read-only
if (!permissions.write && !permissions.trackedWrite) {
spellCheckLanguage = ''
}
const [projectFeatures] =
useScopeValue<Record<string, boolean | string | number | undefined>>(
'project.features'
)
const hunspellManager = useHunspell(spellCheckLanguage)
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
@@ -19,7 +19,8 @@ export const WordCountClient: FC = () => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [data, setData] = useState<WordCountData | null>(null)
const { projectSnapshot, rootDocId } = useProjectContext()
const { projectSnapshot, project } = useProjectContext()
const rootDocId = project?.rootDocId
const { spellCheckLanguage } = useProjectSettingsContext()
const { openDocs } = useEditorManagerContext()
const { currentDocument } = useEditorOpenDocContext()
@@ -10,7 +10,7 @@ import { debugConsole } from '@/utils/debugging'
import { WordCounts } from '@/features/word-count-modal/components/word-counts'
export const WordCountServer: FC = () => {
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
const { clsiServerId } = useLocalCompileContext()
const [loading, setLoading] = useState(true)
@@ -9,9 +9,7 @@ import {
useMemo,
useState,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import useBrowserWindow from '../hooks/use-browser-window'
import { useIdeContext } from './ide-context'
import { useProjectContext } from './project-context'
import { useDetachContext } from './detach-context'
import getMeta from '../../utils/meta'
@@ -46,12 +44,18 @@ export const EditorContext = createContext<
>(undefined)
export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
const { socket } = useIdeContext()
const { id: userId, featureUsage } = useUserContext()
const { role } = useDetachContext()
const { showGenericMessageModal } = useModalsContext()
const { owner, features, _id: projectId, members } = useProjectContext()
const {
features,
projectId,
project,
name: projectName,
updateProject,
} = useProjectContext()
const { owner, members } = project || {}
const cobranding = useMemo(() => {
const brandVariation = getMeta('ol-brandVariation')
@@ -71,8 +75,6 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
)
}, [])
const [projectName, setProjectName] = useScopeValue('project.name')
const [inactiveTutorials, setInactiveTutorials] = useState(
() => getMeta('ol-inactiveTutorials') || []
)
@@ -95,10 +97,12 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
const isPendingEditor = useMemo(
() =>
members?.some(
member =>
member._id === userId &&
(member.pendingEditor || member.pendingReviewer)
Boolean(
members?.some(
member =>
member._id === userId &&
(member.pendingEditor || member.pendingReviewer)
)
),
[members, userId]
)
@@ -110,33 +114,25 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
[inactiveTutorials]
)
useEffect(() => {
if (socket) {
socket.on('projectNameUpdated', setProjectName)
return () => socket.removeListener('projectNameUpdated', setProjectName)
}
}, [socket, setProjectName])
const renameProject = useCallback(
(newName: string) => {
setProjectName((oldName: string) => {
if (oldName !== newName) {
saveProjectSettings(projectId, { name: newName }).catch(
(response: any) => {
setProjectName(oldName)
const { data, status } = response
const oldName = projectName
if (newName !== oldName) {
updateProject({ name: newName })
saveProjectSettings(projectId, { name: newName }).catch(
(response: any) => {
updateProject({ name: oldName })
const { data, status } = response
showGenericMessageModal(
'Error renaming project',
status === 400 ? data : 'Please try again in a moment'
)
}
)
}
return newName
})
showGenericMessageModal(
'Error renaming project',
status === 400 ? data : 'Please try again in a moment'
)
}
)
}
},
[setProjectName, projectId, showGenericMessageModal]
[projectName, updateProject, projectId, showGenericMessageModal]
)
const { setTitle } = useBrowserWindow()
@@ -8,7 +8,6 @@ import {
FC,
useEffect,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import {
renameInTree,
deleteInTree,
@@ -20,7 +19,6 @@ import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { Folder } from '../../../../types/folder'
import { Project } from '../../../../types/project'
import { MainDocument } from '../../../../types/project-settings'
import { FindResult } from '@/features/file-tree/util/path'
import {
@@ -28,6 +26,7 @@ import {
useSnapshotContext,
} from '@/features/ide-react/context/snapshot-context'
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
import { useProjectContext } from '@/shared/context/project-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
const { buildFileTree, createFolder } =
@@ -182,7 +181,7 @@ export function useFileTreeData() {
export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const [project] = useScopeValue<Project>('project')
const { project } = useProjectContext()
const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext()
const { permissionsLevel } = useIdeReactContext()
const { fileTreeFromHistory, snapshot, snapshotVersion } =
@@ -195,7 +194,7 @@ export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
useEffect(() => {
if (fileTreeFromHistory) return
setRootFolder(project?.rootFolder)
}, [project, fileTreeFromHistory])
}, [project?.rootFolder, fileTreeFromHistory])
useEffect(() => {
if (!fileTreeFromHistory) return
@@ -131,13 +131,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
const { currentDocument } = useEditorOpenDocContext()
const { role } = useDetachContext()
const {
_id: projectId,
rootDocId,
joinedOnce,
imageName,
compiler: compilerName,
} = useProjectContext()
const { projectId, joinedOnce, project } = useProjectContext()
const { rootDocId, imageName, compiler: compilerName } = project || {}
const { pdfPreviewOpen } = useLayoutContext()
@@ -1,10 +1,31 @@
import { FC, createContext, useContext, useMemo, useState } from 'react'
import useScopeValue from '../hooks/use-scope-value'
import {
FC,
createContext,
useContext,
useMemo,
useState,
useCallback,
} from 'react'
import getMeta from '@/utils/meta'
import { ProjectContextValue } from './types/project-context'
import { ProjectUpdate, ProjectMetadata } from './types/project-metadata'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
import { Tag } from '../../../../app/src/Features/Tags/types'
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
type ProjectContextValue = {
projectId: ProjectMetadata['_id']
project: ProjectMetadata | null
joinProject: (project: ProjectMetadata) => void
updateProject: (projectUpdate: ProjectUpdate) => void
joinedOnce: boolean
projectSnapshot: ProjectSnapshot
tags: Tag[]
features: ProjectMetadata['features']
name: ProjectMetadata['name']
}
export const ProjectContext = createContext<ProjectContextValue | undefined>(
undefined
)
export function useProjectContext() {
const context = useContext(ProjectContext)
@@ -18,35 +39,33 @@ export function useProjectContext() {
return context
}
// when the provider is created the project is still not added to the Angular
// scope. A few props are populated to prevent errors in existing React
// components
const projectFallback = {
_id: getMeta('ol-project_id'),
name: '',
features: {},
}
export const ProjectProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [project] = useScopeValue('project')
const joinedOnce = !!project
const [joinedOnce, setJoinedOnce] = useState(false)
const [project, setProject] = useState<ProjectMetadata | null>(null)
const {
_id,
compiler,
imageName,
name,
rootDocId,
members,
invites,
features,
publicAccesLevel: publicAccessLevel,
owner,
trackChangesState,
mainBibliographyDoc_id: mainBibliographyDocId,
} = project || projectFallback
// Expose some project properties with fallbacks for convenience
const projectId = project ? project._id : getMeta('ol-project_id')
const name = project ? project.name : ''
const features = project ? project.features : {}
const [projectSnapshot] = useState(() => new ProjectSnapshot(_id))
const joinProject = useCallback((projectData: ProjectMetadata) => {
setProject(projectData)
setJoinedOnce(true)
}, [])
const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
setProject(projectData => {
// Only perform the update if `project` is already set, otherwise we could
// end up with an incomplete project object
if (!projectData) {
throw new Error('Project not initialized. Use joinProject first.')
}
return Object.assign({}, projectData, projectUpdateData)
})
}, [])
const [projectSnapshot] = useState(() => new ProjectSnapshot(projectId))
const tags = useMemo(
() =>
@@ -56,41 +75,17 @@ export const ProjectProvider: FC<React.PropsWithChildren> = ({ children }) => {
[]
)
const value = useMemo(() => {
return {
_id,
compiler,
imageName,
name,
rootDocId,
members,
invites,
features,
publicAccessLevel,
owner,
tags,
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
joinedOnce,
}
}, [
_id,
compiler,
imageName,
name,
rootDocId,
members,
invites,
features,
publicAccessLevel,
owner,
tags,
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
const value = {
projectId,
project,
joinProject,
updateProject,
joinedOnce,
])
projectSnapshot,
tags,
features,
name,
}
return (
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
@@ -1,9 +1,9 @@
import { UserId } from '../../../../../types/user'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import { ProjectSettings } from '@/features/editor-left-menu/utils/api'
import { Folder } from '../../../../../types/folder'
export type ProjectContextMember = {
export type ProjectMember = {
_id: UserId
privileges: 'readOnly' | 'readAndWrite' | 'review'
email: string
@@ -13,15 +13,11 @@ export type ProjectContextMember = {
pendingReviewer?: boolean
}
export type ProjectContextValue = {
export interface ProjectMetadata extends ProjectSettings {
_id: string
name: string
rootDocId?: string
mainBibliographyDocId?: string
compiler: string
imageName: string
members: ProjectContextMember[]
invites: ProjectContextMember[]
members: ProjectMember[]
invites: ProjectMember[]
features: {
collaborators?: number
compileGroup?: 'alpha' | 'standard' | 'priority'
@@ -44,10 +40,8 @@ export type ProjectContextValue = {
privileges: string
signUpDate: string
}
tags: Tag[]
rootFolder?: Folder[]
trackChangesState: boolean | Record<UserId | '__guests__', boolean>
projectSnapshot: ProjectSnapshot
joinedOnce: boolean
}
export type ProjectContextUpdateValue = Partial<ProjectContextValue>
export type ProjectUpdate = Partial<ProjectMetadata>
@@ -10,7 +10,7 @@ type UseStopOnFirstErrorProps = {
export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) {
const { eventSource } = opts
const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
const { _id: projectId } = useProjectContext()
const { projectId } = useProjectContext()
type Opts = {
projectId: string