metadata-context.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import {
  2. createContext,
  3. useContext,
  4. useEffect,
  5. FC,
  6. useCallback,
  7. useMemo,
  8. useState,
  9. useRef,
  10. } from 'react'
  11. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  12. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  13. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  14. import _ from 'lodash'
  15. import { getJSON, postJSON } from '@/infrastructure/fetch-json'
  16. import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
  17. import { useEditorContext } from '@/shared/context/editor-context'
  18. import { useIdeContext } from '@/shared/context/ide-context'
  19. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  20. import useEventListener from '@/shared/hooks/use-event-listener'
  21. import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
  22. import { Project } from '../../../../../types/project'
  23. type DocumentMetadata = {
  24. labels: string[]
  25. packages: Record<string, any>
  26. }
  27. type DocumentsMetadata = Record<string, DocumentMetadata>
  28. type MetadataContextValue = {
  29. metadata: {
  30. state: {
  31. documents: DocumentsMetadata
  32. }
  33. getAllLabels: () => DocumentMetadata['labels']
  34. getAllPackages: () => DocumentMetadata['packages']
  35. }
  36. }
  37. type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
  38. const MetadataContext = createContext<MetadataContextValue | undefined>(
  39. undefined
  40. )
  41. export const MetadataProvider: FC = ({ children }) => {
  42. const ide = useIdeContext()
  43. const { eventEmitter, projectId } = useIdeReactContext()
  44. const { socket } = useConnectionContext()
  45. const { onlineUsersCount } = useOnlineUsersContext()
  46. const { permissionsLevel } = useEditorContext()
  47. const { currentDocument } = useEditorManagerContext()
  48. const [documents, setDocuments] = useState<DocumentsMetadata>({})
  49. const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
  50. useEffect(() => {
  51. const handleEntityDeleted = (entity: FileTreeFindResult) => {
  52. if (entity.type === 'doc') {
  53. setDocuments(documents => {
  54. return _.omit(documents, entity.entity._id)
  55. })
  56. }
  57. }
  58. eventEmitter.on('entity:deleted', handleEntityDeleted)
  59. return () => {
  60. eventEmitter.off('entity:deleted', handleEntityDeleted)
  61. }
  62. }, [eventEmitter])
  63. useEffect(() => {
  64. window.dispatchEvent(
  65. new CustomEvent('project:metadata', { detail: documents })
  66. )
  67. }, [documents])
  68. const onBroadcastDocMeta = useCallback((data: DocMetadataResponse) => {
  69. const { docId, meta } = data
  70. if (docId != null && meta != null) {
  71. setDocuments(documents => ({ ...documents, [docId]: meta }))
  72. }
  73. }, [])
  74. const getAllLabels = useCallback(
  75. () => _.flattenDeep(Object.values(documents).map(meta => meta.labels)),
  76. [documents]
  77. )
  78. const getAllPackages = useCallback(() => {
  79. const packageCommandMapping: Record<string, any> = {}
  80. for (const meta of Object.values(documents)) {
  81. for (const [packageName, commandSnippets] of Object.entries(
  82. meta.packages
  83. )) {
  84. packageCommandMapping[packageName] = commandSnippets
  85. }
  86. }
  87. return packageCommandMapping
  88. }, [documents])
  89. const loadProjectMetaFromServer = useCallback(() => {
  90. getJSON(`/project/${projectId}/metadata`).then(
  91. (response: { projectMeta: DocumentsMetadata }) => {
  92. const { projectMeta } = response
  93. if (projectMeta) {
  94. setDocuments(projectMeta)
  95. }
  96. }
  97. )
  98. }, [projectId])
  99. const loadDocMetaFromServer = useCallback(
  100. (docId: string) => {
  101. // Don't broadcast metadata when there are no other users in the
  102. // project.
  103. const broadcast = onlineUsersCount > 0
  104. postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
  105. body: {
  106. broadcast,
  107. },
  108. }).then((response: DocMetadataResponse) => {
  109. if (!broadcast && response) {
  110. // handle the POST response like a broadcast event when there are no
  111. // other users in the project.
  112. onBroadcastDocMeta(response)
  113. }
  114. })
  115. },
  116. [onBroadcastDocMeta, onlineUsersCount, projectId]
  117. )
  118. const scheduleLoadDocMetaFromServer = useCallback(
  119. (docId: string) => {
  120. if (permissionsLevel === 'readOnly') {
  121. // The POST request is blocked for users without write permission.
  122. // The user will not be able to consume the metadata for edits anyway.
  123. return
  124. }
  125. // Debounce loading labels with a timeout
  126. const existingTimeout = debouncerRef.current.get(docId)
  127. if (existingTimeout != null) {
  128. window.clearTimeout(existingTimeout)
  129. debouncerRef.current.delete(docId)
  130. }
  131. debouncerRef.current.set(
  132. docId,
  133. window.setTimeout(() => {
  134. // TODO: wait for the document to be saved?
  135. loadDocMetaFromServer(docId)
  136. debouncerRef.current.delete(docId)
  137. }, 2000)
  138. )
  139. },
  140. [loadDocMetaFromServer, permissionsLevel]
  141. )
  142. const handleBroadcastDocMeta = useCallback(
  143. (data: DocMetadataResponse) => {
  144. onBroadcastDocMeta(data)
  145. },
  146. [onBroadcastDocMeta]
  147. )
  148. useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
  149. const handleMetadataOutdated = useCallback(() => {
  150. if (currentDocument) {
  151. scheduleLoadDocMetaFromServer(currentDocument.doc_id)
  152. }
  153. }, [currentDocument, scheduleLoadDocMetaFromServer])
  154. useEventListener('editor:metadata-outdated', handleMetadataOutdated)
  155. useEffect(() => {
  156. const handleProjectJoined = ({ project }: { project: Project }) => {
  157. if (project.deletedByExternalDataSource) {
  158. // TODO: MIGRATION: Show generic message modal here
  159. /*
  160. ide.showGenericMessageModal(
  161. 'Project Renamed or Deleted',
  162. `\
  163. This project has either been renamed or deleted by an external data source such as Dropbox.
  164. We don't want to delete your data on Overleaf, so this project still contains your history and collaborators.
  165. If the project has been renamed please look in your project list for a new project under the new name.\
  166. `
  167. )
  168. */
  169. }
  170. window.setTimeout(() => {
  171. if (permissionsLevel !== 'readOnly') {
  172. loadProjectMetaFromServer()
  173. }
  174. }, 200)
  175. }
  176. eventEmitter.once('project:joined', handleProjectJoined)
  177. return () => {
  178. eventEmitter.off('project:joined', handleProjectJoined)
  179. }
  180. }, [eventEmitter, loadProjectMetaFromServer, permissionsLevel])
  181. const value = useMemo<MetadataContextValue>(
  182. () => ({
  183. metadata: {
  184. state: { documents },
  185. getAllLabels,
  186. getAllPackages,
  187. },
  188. }),
  189. [documents, getAllLabels, getAllPackages]
  190. )
  191. // Expose metadataManager via ide object because useCodeMirrorScope relies on
  192. // it, for now
  193. ide.metadataManager = value
  194. return (
  195. <MetadataContext.Provider value={value}>
  196. {children}
  197. </MetadataContext.Provider>
  198. )
  199. }
  200. export function useMetadataContext(): MetadataContextValue {
  201. const context = useContext(MetadataContext)
  202. if (!context) {
  203. throw new Error(
  204. 'useMetadataContext is only available inside MetadataProvider'
  205. )
  206. }
  207. return context
  208. }