metadata-context.tsx 7.1 KB

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