share-project-modal.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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 {
  11. useFeatureFlag,
  12. useSplitTestContext,
  13. } from '@/shared/context/split-test-context'
  14. import { sendMB } from '@/infrastructure/event-tracking'
  15. import { useEditorContext } from '@/shared/context/editor-context'
  16. import customLocalStorage from '@/infrastructure/local-storage'
  17. import { FetchError } from '@/infrastructure/fetch-json'
  18. import {
  19. getSharingLink,
  20. SharingLinkData,
  21. } from '@/features/share-project-modal/utils/api'
  22. export type ProjectAccessType =
  23. | 'legacyLinkSharing'
  24. | 'onlyInvitedPeople'
  25. | `anyoneInXyzWithTheLink.${string}`
  26. | 'anyoneWithTheLink'
  27. export type ShareProjectContextValue = {
  28. monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
  29. inFlight: boolean
  30. setInFlight: React.Dispatch<
  31. React.SetStateAction<ShareProjectContextValue['inFlight']>
  32. >
  33. error: string | undefined
  34. setError: React.Dispatch<
  35. React.SetStateAction<ShareProjectContextValue['error']>
  36. >
  37. successActionMessage: string | undefined
  38. setSuccessActionMessage: React.Dispatch<
  39. React.SetStateAction<string | undefined>
  40. >
  41. projectAccess: ProjectAccessType | undefined
  42. setProjectAccess: React.Dispatch<
  43. React.SetStateAction<ProjectAccessType | undefined>
  44. >
  45. sharingLinkData: SharingLinkData | null
  46. setSharingLinkData: (data: SharingLinkData | null) => void
  47. }
  48. const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
  49. const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
  50. undefined
  51. )
  52. export function useShareProjectContext() {
  53. const context = useContext(ShareProjectContext)
  54. if (!context) {
  55. throw new Error(
  56. 'useShareProjectContext is only available inside ShareProjectProvider'
  57. )
  58. }
  59. return context
  60. }
  61. type ShareProjectModalProps = {
  62. handleHide: () => void
  63. show: boolean
  64. handleOpen: () => void
  65. animation?: boolean
  66. }
  67. const ShareProjectModal = React.memo(function ShareProjectModal({
  68. handleHide,
  69. show,
  70. handleOpen,
  71. animation = true,
  72. }: ShareProjectModalProps) {
  73. const [inFlight, setInFlight] =
  74. useState<ShareProjectContextValue['inFlight']>(false)
  75. const [error, setError] = useState<ShareProjectContextValue['error']>()
  76. const [sharingLinkData, setSharingLinkData] =
  77. useState<SharingLinkData | null>(null)
  78. const [projectAccess, setProjectAccess] = useState<
  79. ProjectAccessType | undefined
  80. >()
  81. const [successActionMessage, setSuccessActionMessage] = useState<
  82. string | undefined
  83. >()
  84. const { project, projectId } = useProjectContext()
  85. const { isProjectOwner } = useEditorContext()
  86. const { publicAccessLevel } = project || {}
  87. const { splitTestVariants } = useSplitTestContext()
  88. const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
  89. // show the new share modal if project owner
  90. // is over collaborator limit or has pending editors (once every 24 hours)
  91. useEffect(() => {
  92. const hasExceededCollaboratorLimit = () => {
  93. if (!isProjectOwner || !project || !project.features) {
  94. return false
  95. }
  96. if (project.features.collaborators === -1) {
  97. return false
  98. }
  99. return (
  100. project.members.filter(member =>
  101. ['readAndWrite', 'review'].includes(member.privileges)
  102. ).length > (project.features.collaborators ?? 1) ||
  103. project.members.some(
  104. member => member.pendingEditor || member.pendingReviewer
  105. )
  106. )
  107. }
  108. if (hasExceededCollaboratorLimit()) {
  109. const localStorageKey = `last-shown-share-modal.${projectId}`
  110. const lastShownShareModalTime =
  111. customLocalStorage.getItem(localStorageKey)
  112. if (
  113. !lastShownShareModalTime ||
  114. lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
  115. ) {
  116. handleOpen()
  117. customLocalStorage.setItem(localStorageKey, Date.now())
  118. }
  119. }
  120. }, [project, isProjectOwner, handleOpen, projectId])
  121. // send tracking event when the modal is opened
  122. useEffect(() => {
  123. if (show) {
  124. sendMB('share-modal-opened', {
  125. splitTestVariant: splitTestVariants['null-test-share-modal'],
  126. project_id: projectId,
  127. })
  128. }
  129. }, [splitTestVariants, projectId, show])
  130. // reset error when the modal is opened
  131. useEffect(() => {
  132. if (show) {
  133. setError(undefined)
  134. }
  135. }, [show])
  136. const handleShow = useCallback(async () => {
  137. if (!isSharingUpdatesEnabled || !isProjectOwner) {
  138. return
  139. }
  140. if (publicAccessLevel === 'tokenBased') {
  141. setSharingLinkData(null)
  142. setProjectAccess('legacyLinkSharing')
  143. return
  144. }
  145. try {
  146. const data = await getSharingLink(projectId)
  147. setSharingLinkData(data)
  148. if (!data.privileges) {
  149. setProjectAccess('onlyInvitedPeople')
  150. } else if (data.subscriptionId) {
  151. setProjectAccess(`anyoneInXyzWithTheLink.${data.subscriptionId}`)
  152. } else {
  153. setProjectAccess('anyoneWithTheLink')
  154. }
  155. } catch (error) {
  156. if (error instanceof FetchError && error.response?.status === 404) {
  157. setSharingLinkData(null)
  158. setProjectAccess('onlyInvitedPeople')
  159. return
  160. }
  161. const errorData = (error as { data?: Record<string, string> })?.data
  162. setError(
  163. errorData?.errorReason ||
  164. errorData?.error ||
  165. 'generic_something_went_wrong'
  166. )
  167. }
  168. }, [publicAccessLevel, projectId, isSharingUpdatesEnabled, isProjectOwner])
  169. // close the modal if not in flight
  170. const cancel = useCallback(() => {
  171. if (!inFlight) {
  172. handleHide()
  173. setSuccessActionMessage(undefined)
  174. }
  175. }, [handleHide, inFlight])
  176. // update `error` and `inFlight` while sending a request
  177. const monitorRequest = useCallback((request: () => any) => {
  178. setError(undefined)
  179. setSuccessActionMessage(undefined)
  180. setInFlight(true)
  181. return request()
  182. .catch((error: { data?: Record<string, string> }) => {
  183. setError(
  184. error.data?.errorReason ||
  185. error.data?.error ||
  186. 'generic_something_went_wrong'
  187. )
  188. throw error
  189. })
  190. .finally(() => {
  191. setInFlight(false)
  192. })
  193. }, [])
  194. if (!project) {
  195. return null
  196. }
  197. return (
  198. <ShareProjectContext.Provider
  199. value={{
  200. monitorRequest,
  201. inFlight,
  202. setInFlight,
  203. error,
  204. setError,
  205. successActionMessage,
  206. setSuccessActionMessage,
  207. projectAccess,
  208. setProjectAccess,
  209. sharingLinkData,
  210. setSharingLinkData,
  211. }}
  212. >
  213. <ShareProjectModalContent
  214. animation={animation}
  215. onShow={handleShow}
  216. cancel={cancel}
  217. error={error}
  218. inFlight={inFlight}
  219. show={show}
  220. projectName={project.name}
  221. />
  222. </ShareProjectContext.Provider>
  223. )
  224. })
  225. export default ShareProjectModal