metadata-context.tsx 7.3 KB

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