share-project-modal.tsx 4.4 KB

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