overleaf/services/web/frontend/js/features/ide-react/context/metadata-context.tsx

244 lines
7.1 KiB
TypeScript
Raw Normal View History

import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
useRef,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import _ from 'lodash'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorContext } from '@/shared/context/editor-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import useEventListener from '@/shared/hooks/use-event-listener'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
type DocumentMetadata = {
labels: string[]
packages: Record<string, any>
}
type DocumentsMetadata = Record<string, DocumentMetadata>
type MetadataContextValue = {
metadata: {
state: {
documents: DocumentsMetadata
}
getAllLabels: () => DocumentMetadata['labels']
getAllPackages: () => DocumentMetadata['packages']
}
}
type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
export const MetadataContext = createContext<MetadataContextValue | undefined>(
undefined
)
export const MetadataProvider: FC = ({ children }) => {
const { t } = useTranslation()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const { onlineUsersCount } = useOnlineUsersContext()
const { permissionsLevel } = useEditorContext()
const permissions = usePermissionsContext()
const { currentDocument } = useEditorManagerContext()
const { showGenericMessageModal } = useModalsContext()
const [documents, setDocuments] = useState<DocumentsMetadata>({})
const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
useEffect(() => {
const handleEntityDeleted = ({
detail: [entity],
}: CustomEvent<IdeEvents['entity:deleted']>) => {
if (entity.type === 'doc') {
setDocuments(documents => {
return _.omit(documents, entity.entity._id)
})
}
}
eventEmitter.on('entity:deleted', handleEntityDeleted)
return () => {
eventEmitter.off('entity:deleted', handleEntityDeleted)
}
}, [eventEmitter])
useEffect(() => {
window.dispatchEvent(
new CustomEvent('project:metadata', { detail: documents })
)
}, [documents])
const onBroadcastDocMeta = useCallback((data: DocMetadataResponse) => {
const { docId, meta } = data
if (docId != null && meta != null) {
setDocuments(documents => ({ ...documents, [docId]: meta }))
}
}, [])
const getAllLabels = useCallback(
() => _.flattenDeep(Object.values(documents).map(meta => meta.labels)),
[documents]
)
const getAllPackages = useCallback(() => {
const packageCommandMapping: Record<string, any> = {}
for (const meta of Object.values(documents)) {
for (const [packageName, commandSnippets] of Object.entries(
meta.packages
)) {
packageCommandMapping[packageName] = commandSnippets
}
}
return packageCommandMapping
}, [documents])
const loadProjectMetaFromServer = useCallback(() => {
getJSON(`/project/${projectId}/metadata`).then(
(response: { projectMeta: DocumentsMetadata }) => {
const { projectMeta } = response
if (projectMeta) {
setDocuments(projectMeta)
}
}
)
}, [projectId])
const loadDocMetaFromServer = useCallback(
(docId: string) => {
// Don't broadcast metadata when there are no other users in the
// project.
const broadcast = onlineUsersCount > 0
postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
body: {
broadcast,
},
}).then((response: DocMetadataResponse) => {
if (!broadcast && response) {
// handle the POST response like a broadcast event when there are no
// other users in the project.
onBroadcastDocMeta(response)
}
})
},
[onBroadcastDocMeta, onlineUsersCount, projectId]
)
const scheduleLoadDocMetaFromServer = useCallback(
(docId: string) => {
if (permissionsLevel === 'readOnly') {
// The POST request is blocked for users without write permission.
// The user will not be able to consume the metadata for edits anyway.
return
}
// Debounce loading labels with a timeout
const existingTimeout = debouncerRef.current.get(docId)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
debouncerRef.current.delete(docId)
}
debouncerRef.current.set(
docId,
window.setTimeout(() => {
// TODO: wait for the document to be saved?
loadDocMetaFromServer(docId)
debouncerRef.current.delete(docId)
}, 2000)
)
},
[loadDocMetaFromServer, permissionsLevel]
)
const handleBroadcastDocMeta = useCallback(
(data: DocMetadataResponse) => {
onBroadcastDocMeta(data)
},
[onBroadcastDocMeta]
)
useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
const handleMetadataOutdated = useCallback(() => {
if (currentDocument) {
scheduleLoadDocMetaFromServer(currentDocument.doc_id)
}
}, [currentDocument, scheduleLoadDocMetaFromServer])
useEventListener('editor:metadata-outdated', handleMetadataOutdated)
const permissionsRef = useRef(permissions)
useEffect(() => {
permissionsRef.current = permissions
}, [permissions])
useEffect(() => {
const handleProjectJoined = ({
detail: [{ project }],
}: CustomEvent<IdeEvents['project:joined']>) => {
if (project.deletedByExternalDataSource) {
showGenericMessageModal(
t('project_renamed_or_deleted'),
t('project_renamed_or_deleted_detail')
)
}
window.setTimeout(() => {
if (permissionsRef.current.write) {
loadProjectMetaFromServer()
}
}, 200)
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, loadProjectMetaFromServer, showGenericMessageModal, t])
const value = useMemo<MetadataContextValue>(
() => ({
metadata: {
state: { documents },
getAllLabels,
getAllPackages,
},
}),
[documents, getAllLabels, getAllPackages]
)
return (
<MetadataContext.Provider value={value}>
{children}
</MetadataContext.Provider>
)
}
export function useMetadataContext(): MetadataContextValue {
const context = useContext(MetadataContext)
if (!context) {
throw new Error(
'useMetadataContext is only available inside MetadataProvider'
)
}
return context
}