ide-react-context.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import React, {
  2. createContext,
  3. useContext,
  4. useState,
  5. FC,
  6. useMemo,
  7. useEffect,
  8. useCallback,
  9. } from 'react'
  10. import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
  11. import { IdeProvider } from '@/shared/context/ide-context'
  12. import {
  13. createIdeEventEmitter,
  14. IdeEventEmitter,
  15. } from '@/features/ide-react/create-ide-event-emitter'
  16. import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
  17. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  18. import { populateEditorScope } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
  19. import { postJSON } from '@/infrastructure/fetch-json'
  20. import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
  21. import getMeta from '@/utils/meta'
  22. import { type PermissionsLevel } from '@/features/ide-react/types/permissions'
  23. const LOADED_AT = new Date()
  24. type IdeReactContextValue = {
  25. projectId: string
  26. eventEmitter: IdeEventEmitter
  27. startedFreeTrial: boolean
  28. setStartedFreeTrial: React.Dispatch<
  29. React.SetStateAction<IdeReactContextValue['startedFreeTrial']>
  30. >
  31. reportError: (error: any, meta?: Record<string, any>) => void
  32. projectJoined: boolean
  33. permissionsLevel: PermissionsLevel
  34. setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void
  35. setOutOfSync: (value: boolean) => void
  36. }
  37. export const IdeReactContext = createContext<IdeReactContextValue | undefined>(
  38. undefined
  39. )
  40. function populateIdeReactScope(store: ReactScopeValueStore) {
  41. store.set('settings', {})
  42. }
  43. function populateProjectScope(store: ReactScopeValueStore) {
  44. store.allowNonExistentPath('project', true)
  45. }
  46. function populatePdfScope(store: ReactScopeValueStore) {
  47. store.allowNonExistentPath('pdf', true)
  48. }
  49. export function createReactScopeValueStore(projectId: string) {
  50. const scopeStore = new ReactScopeValueStore()
  51. // Populate the scope value store with default values that will be used by
  52. // nested contexts that refer to scope values. The ideal would be to leave
  53. // initialization of store values up to the nested context, which would keep
  54. // initialization code together with the context and would only populate
  55. // necessary values in the store, but this is simpler for now
  56. populateIdeReactScope(scopeStore)
  57. populateEditorScope(scopeStore, projectId)
  58. populateProjectScope(scopeStore)
  59. populatePdfScope(scopeStore)
  60. scopeStore.allowNonExistentPath('hasLintingError')
  61. return scopeStore
  62. }
  63. export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
  64. const projectId = getMeta('ol-project_id')
  65. const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
  66. const [eventEmitter] = useState(createIdeEventEmitter)
  67. const [permissionsLevel, setPermissionsLevel] =
  68. useState<PermissionsLevel>('readOnly')
  69. const [outOfSync, setOutOfSync] = useState(false)
  70. const [scopeEventEmitter] = useState(
  71. () => new ReactScopeEventEmitter(eventEmitter)
  72. )
  73. const [unstableStore] = useState(() => {
  74. const store = new ReactScopeValueStore()
  75. // Add dummy editor.ready key for Writefull, that relies on this calling
  76. // back once after watching it
  77. store.set('editor.ready', undefined)
  78. return store
  79. })
  80. const [startedFreeTrial, setStartedFreeTrial] = useState(false)
  81. const release = getMeta('ol-ExposedSettings')?.sentryRelease ?? null
  82. // Set to true only after project:joined has fired and all its listeners have
  83. // been called
  84. const [projectJoined, setProjectJoined] = useState(false)
  85. const { socket, getSocketDebuggingInfo } = useConnectionContext()
  86. const reportError = useCallback(
  87. (error: any, meta?: Record<string, any>) => {
  88. const metadata = {
  89. ...meta,
  90. user_id: getMeta('ol-user_id'),
  91. project_id: projectId,
  92. client_now: new Date(),
  93. performance_now: performance.now(),
  94. release,
  95. client_load: LOADED_AT,
  96. spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
  97. ...getSocketDebuggingInfo(),
  98. }
  99. const errorObj: Record<string, any> = {}
  100. if (typeof error === 'object') {
  101. for (const key of Object.getOwnPropertyNames(error)) {
  102. errorObj[key] = error[key]
  103. }
  104. } else if (typeof error === 'string') {
  105. errorObj.message = error
  106. }
  107. return postJSON('/error/client', {
  108. body: {
  109. error: errorObj,
  110. meta: metadata,
  111. },
  112. })
  113. },
  114. [release, projectId, getSocketDebuggingInfo, scopeStore]
  115. )
  116. // Populate scope values when joining project, then fire project:joined event
  117. useEffect(() => {
  118. function handleJoinProjectResponse({
  119. project: { rootDoc_id: rootDocId, ..._project },
  120. permissionsLevel,
  121. }: JoinProjectPayload) {
  122. const project = { ..._project, rootDocId }
  123. scopeStore.set('project', project)
  124. setPermissionsLevel(permissionsLevel)
  125. // Make watchers update immediately
  126. scopeStore.flushUpdates()
  127. eventEmitter.emit('project:joined', { project, permissionsLevel })
  128. setProjectJoined(true)
  129. }
  130. function handleMainBibliographyDocUpdated(payload: string) {
  131. scopeStore.set('project.mainBibliographyDoc_id', payload)
  132. }
  133. socket.on('joinProjectResponse', handleJoinProjectResponse)
  134. socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated)
  135. return () => {
  136. socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
  137. socket.removeListener(
  138. 'mainBibliographyDocUpdated',
  139. handleMainBibliographyDocUpdated
  140. )
  141. }
  142. }, [socket, eventEmitter, scopeStore])
  143. const ide = useMemo(() => {
  144. return {
  145. _id: projectId,
  146. socket,
  147. reportError,
  148. }
  149. }, [projectId, socket, reportError])
  150. const value = useMemo(
  151. () => ({
  152. eventEmitter,
  153. startedFreeTrial,
  154. setStartedFreeTrial,
  155. permissionsLevel: outOfSync ? 'readOnly' : permissionsLevel,
  156. setPermissionsLevel,
  157. setOutOfSync,
  158. projectId,
  159. reportError,
  160. projectJoined,
  161. }),
  162. [
  163. eventEmitter,
  164. outOfSync,
  165. permissionsLevel,
  166. projectId,
  167. projectJoined,
  168. reportError,
  169. startedFreeTrial,
  170. ]
  171. )
  172. return (
  173. <IdeReactContext.Provider value={value}>
  174. <IdeProvider
  175. ide={ide}
  176. scopeStore={scopeStore}
  177. scopeEventEmitter={scopeEventEmitter}
  178. unstableStore={unstableStore}
  179. >
  180. {children}
  181. </IdeProvider>
  182. </IdeReactContext.Provider>
  183. )
  184. }
  185. export function useIdeReactContext(): IdeReactContextValue {
  186. const context = useContext(IdeReactContext)
  187. if (!context) {
  188. throw new Error(
  189. 'useIdeReactContext is only available inside IdeReactProvider'
  190. )
  191. }
  192. return context
  193. }