share-project-modal.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import React, {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useState,
  7. } from 'react'
  8. import ShareProjectModalContent from './share-project-modal-content'
  9. import { useProjectContext } from '@/shared/context/project-context'
  10. import { useSplitTestContext } from '@/shared/context/split-test-context'
  11. import { sendMB } from '@/infrastructure/event-tracking'
  12. import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
  13. import { useEditorContext } from '@/shared/context/editor-context'
  14. import customLocalStorage from '@/infrastructure/local-storage'
  15. type ShareProjectContextValue = {
  16. updateProject: (project: ProjectContextUpdateValue) => void
  17. monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
  18. inFlight: boolean
  19. setInFlight: React.Dispatch<
  20. React.SetStateAction<ShareProjectContextValue['inFlight']>
  21. >
  22. error: string | undefined
  23. setError: React.Dispatch<
  24. React.SetStateAction<ShareProjectContextValue['error']>
  25. >
  26. }
  27. const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
  28. const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
  29. undefined
  30. )
  31. export function useShareProjectContext() {
  32. const context = useContext(ShareProjectContext)
  33. if (!context) {
  34. throw new Error(
  35. 'useShareProjectContext is only available inside ShareProjectProvider'
  36. )
  37. }
  38. return context
  39. }
  40. type ShareProjectModalProps = {
  41. handleHide: () => void
  42. show: boolean
  43. handleOpen: () => void
  44. animation?: boolean
  45. }
  46. const ShareProjectModal = React.memo(function ShareProjectModal({
  47. handleHide,
  48. show,
  49. handleOpen,
  50. animation = true,
  51. }: ShareProjectModalProps) {
  52. const [inFlight, setInFlight] =
  53. useState<ShareProjectContextValue['inFlight']>(false)
  54. const [error, setError] = useState<ShareProjectContextValue['error']>()
  55. const project = useProjectContext()
  56. const { isProjectOwner } = useEditorContext()
  57. const { splitTestVariants } = useSplitTestContext()
  58. // show the new share modal if project owner
  59. // is over collaborator limit or has pending editors (once every 24 hours)
  60. useEffect(() => {
  61. const hasExceededCollaboratorLimit = () => {
  62. if (!isProjectOwner || !project.features) {
  63. return false
  64. }
  65. if (project.features.collaborators === -1) {
  66. return false
  67. }
  68. return (
  69. project.members.filter(member =>
  70. ['readAndWrite', 'review'].includes(member.privileges)
  71. ).length > (project.features.collaborators ?? 1) ||
  72. project.members.some(
  73. member => member.pendingEditor || member.pendingReviewer
  74. )
  75. )
  76. }
  77. if (hasExceededCollaboratorLimit()) {
  78. const localStorageKey = `last-shown-share-modal.${project._id}`
  79. const lastShownShareModalTime =
  80. customLocalStorage.getItem(localStorageKey)
  81. if (
  82. !lastShownShareModalTime ||
  83. lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
  84. ) {
  85. handleOpen()
  86. customLocalStorage.setItem(localStorageKey, Date.now())
  87. }
  88. }
  89. }, [project, isProjectOwner, handleOpen])
  90. // send tracking event when the modal is opened
  91. useEffect(() => {
  92. if (show) {
  93. sendMB('share-modal-opened', {
  94. splitTestVariant: splitTestVariants['null-test-share-modal'],
  95. project_id: project._id,
  96. })
  97. }
  98. }, [splitTestVariants, project._id, show])
  99. // reset error when the modal is opened
  100. useEffect(() => {
  101. if (show) {
  102. setError(undefined)
  103. }
  104. }, [show])
  105. // close the modal if not in flight
  106. const cancel = useCallback(() => {
  107. if (!inFlight) {
  108. handleHide()
  109. }
  110. }, [handleHide, inFlight])
  111. // update `error` and `inFlight` while sending a request
  112. const monitorRequest = useCallback((request: () => any) => {
  113. setError(undefined)
  114. setInFlight(true)
  115. const promise = request()
  116. promise.catch((error: { data?: Record<string, string> }) => {
  117. setError(
  118. error.data?.errorReason ||
  119. error.data?.error ||
  120. 'generic_something_went_wrong'
  121. )
  122. })
  123. promise.finally(() => {
  124. setInFlight(false)
  125. })
  126. return promise
  127. }, [])
  128. // merge the new data with the old project data
  129. const updateProject = useCallback(
  130. (data: ProjectContextUpdateValue) => Object.assign(project, data),
  131. [project]
  132. )
  133. if (!project) {
  134. return null
  135. }
  136. return (
  137. <ShareProjectContext.Provider
  138. value={{
  139. updateProject,
  140. monitorRequest,
  141. inFlight,
  142. setInFlight,
  143. error,
  144. setError,
  145. }}
  146. >
  147. <ShareProjectModalContent
  148. animation={animation}
  149. cancel={cancel}
  150. error={error}
  151. inFlight={inFlight}
  152. show={show}
  153. />
  154. </ShareProjectContext.Provider>
  155. )
  156. })
  157. export default ShareProjectModal