metadata-context.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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 { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
  14. import { getJSON, postJSON } from '@/infrastructure/fetch-json'
  15. import { debugConsole } from '@/utils/debugging'
  16. import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
  17. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  18. import useEventListener from '@/shared/hooks/use-event-listener'
  19. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  20. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  21. import { useTranslation } from 'react-i18next'
  22. import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
  23. export type Command = {
  24. caption: string
  25. snippet: string
  26. meta: string
  27. score: number
  28. }
  29. export type DocumentMetadata = {
  30. labels: string[]
  31. packages: Record<string, Command[]>
  32. packageNames: string[]
  33. }
  34. type DocumentsMetadata = Record<string, DocumentMetadata>
  35. type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
  36. export const MetadataContext = createContext<
  37. | {
  38. commands: Command[]
  39. labels: Set<string>
  40. packageNames: Set<string>
  41. }
  42. | undefined
  43. >(undefined)
  44. export const MetadataProvider: FC<React.PropsWithChildren> = ({ children }) => {
  45. const { t } = useTranslation()
  46. const { eventEmitter, permissionsLevel, projectId } = useIdeReactContext()
  47. const { socket } = useConnectionContext()
  48. const { onlineUsersCount } = useOnlineUsersContext()
  49. const permissions = usePermissionsContext()
  50. const { currentDocument } = useEditorOpenDocContext()
  51. const { showGenericMessageModal } = useModalsContext()
  52. const [documents, setDocuments] = useState<DocumentsMetadata>({})
  53. const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
  54. useEffect(() => {
  55. const handleEntityDeleted = ({
  56. detail: [entity],
  57. }: CustomEvent<IdeEvents['entity:deleted']>) => {
  58. if (entity.type === 'doc') {
  59. setDocuments(documents => {
  60. delete documents[entity.entity._id]
  61. return { ...documents }
  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 loadProjectMetaFromServer = useCallback(() => {
  82. getJSON(`/project/${projectId}/metadata`)
  83. .then((response: { projectMeta: DocumentsMetadata }) => {
  84. const { projectMeta } = response
  85. if (projectMeta) {
  86. setDocuments(projectMeta)
  87. }
  88. })
  89. .catch(debugConsole.error)
  90. }, [projectId])
  91. const loadDocMetaFromServer = useCallback(
  92. (docId: string) => {
  93. // Don't broadcast metadata when there are no other users in the
  94. // project.
  95. const broadcast = onlineUsersCount > 0
  96. postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
  97. body: {
  98. broadcast,
  99. },
  100. })
  101. .then((response: DocMetadataResponse) => {
  102. if (!broadcast && response) {
  103. // handle the POST response like a broadcast event when there are no
  104. // other users in the project.
  105. onBroadcastDocMeta(response)
  106. }
  107. })
  108. .catch(debugConsole.error)
  109. },
  110. [onBroadcastDocMeta, onlineUsersCount, projectId]
  111. )
  112. const scheduleLoadDocMetaFromServer = useCallback(
  113. (docId: string) => {
  114. if (permissionsLevel === 'readOnly') {
  115. // The POST request is blocked for users without write permission.
  116. // The user will not be able to consume the metadata for edits anyway.
  117. return
  118. }
  119. // Debounce loading labels with a timeout
  120. const existingTimeout = debouncerRef.current.get(docId)
  121. if (existingTimeout != null) {
  122. window.clearTimeout(existingTimeout)
  123. debouncerRef.current.delete(docId)
  124. }
  125. debouncerRef.current.set(
  126. docId,
  127. window.setTimeout(() => {
  128. // TODO: wait for the document to be saved?
  129. loadDocMetaFromServer(docId)
  130. debouncerRef.current.delete(docId)
  131. }, 2000)
  132. )
  133. },
  134. [loadDocMetaFromServer, permissionsLevel]
  135. )
  136. const handleBroadcastDocMeta = useCallback(
  137. (data: DocMetadataResponse) => {
  138. onBroadcastDocMeta(data)
  139. },
  140. [onBroadcastDocMeta]
  141. )
  142. useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
  143. const handleMetadataOutdated = useCallback(() => {
  144. if (currentDocument) {
  145. scheduleLoadDocMetaFromServer(currentDocument.doc_id)
  146. }
  147. }, [currentDocument, scheduleLoadDocMetaFromServer])
  148. useEventListener('editor:metadata-outdated', handleMetadataOutdated)
  149. const permissionsRef = useRef(permissions)
  150. useEffect(() => {
  151. permissionsRef.current = permissions
  152. }, [permissions])
  153. useEffect(() => {
  154. const handleProjectJoined = ({
  155. detail: [{ project }],
  156. }: CustomEvent<IdeEvents['project:joined']>) => {
  157. if (project.deletedByExternalDataSource) {
  158. showGenericMessageModal(
  159. t('project_renamed_or_deleted'),
  160. t('project_renamed_or_deleted_detail')
  161. )
  162. }
  163. window.setTimeout(() => {
  164. if (
  165. permissionsRef.current.write ||
  166. permissionsRef.current.trackedWrite
  167. ) {
  168. loadProjectMetaFromServer()
  169. }
  170. }, 200)
  171. }
  172. eventEmitter.once('project:joined', handleProjectJoined)
  173. return () => {
  174. eventEmitter.off('project:joined', handleProjectJoined)
  175. }
  176. }, [eventEmitter, loadProjectMetaFromServer, showGenericMessageModal, t])
  177. const value = useMemo(() => {
  178. const docs = Object.values(documents)
  179. return {
  180. commands: docs.flatMap(doc => Object.values(doc.packages).flat()),
  181. labels: new Set(docs.flatMap(doc => doc.labels)),
  182. packageNames: new Set(docs.flatMap(doc => doc.packageNames)),
  183. }
  184. }, [documents])
  185. return (
  186. <MetadataContext.Provider value={value}>
  187. {children}
  188. </MetadataContext.Provider>
  189. )
  190. }
  191. export function useMetadataContext() {
  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. }