metadata-context.tsx 6.7 KB

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