GooglePickerView.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { h } from 'preact'
  2. import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
  3. import type { Uppy } from '@uppy/core'
  4. import type { AsyncStore } from '@uppy/core/lib/Uppy.js'
  5. import {
  6. authorize,
  7. ensureScriptsInjected,
  8. InvalidTokenError,
  9. logout,
  10. pollPickingSession,
  11. showDrivePicker,
  12. showPhotosPicker,
  13. type PickedItem,
  14. type PickingSession,
  15. } from './googlePicker.js'
  16. import AuthView from '../ProviderView/AuthView.js'
  17. import { GoogleDriveIcon, GooglePhotosIcon } from './icons.js'
  18. function useStore(
  19. store: AsyncStore,
  20. key: string,
  21. ): [string | undefined | null, (v: string | null) => Promise<void>] {
  22. const [value, setValueState] = useState<string | null | undefined>()
  23. useEffect(() => {
  24. ;(async () => {
  25. setValueState(await store.getItem(key))
  26. })()
  27. }, [key, store])
  28. const setValue = useCallback(
  29. async (v: string | null) => {
  30. setValueState(v)
  31. if (v == null) {
  32. return store.removeItem(key)
  33. }
  34. return store.setItem(key, v)
  35. },
  36. [key, store],
  37. )
  38. return [value, setValue]
  39. }
  40. export type GooglePickerViewProps = {
  41. uppy: Uppy<any, any>
  42. clientId: string
  43. onFilesPicked: (files: PickedItem[], accessToken: string) => void
  44. storage: AsyncStore
  45. } & (
  46. | {
  47. pickerType: 'drive'
  48. apiKey: string
  49. appId: string
  50. }
  51. | {
  52. pickerType: 'photos'
  53. apiKey?: undefined
  54. appId?: undefined
  55. }
  56. )
  57. export default function GooglePickerView({
  58. uppy,
  59. clientId,
  60. onFilesPicked,
  61. pickerType,
  62. apiKey,
  63. appId,
  64. storage,
  65. }: GooglePickerViewProps) {
  66. const [loading, setLoading] = useState(false)
  67. const [accessToken, setAccessTokenStored] = useStore(
  68. storage,
  69. `uppy:google-${pickerType}-picker:accessToken`,
  70. )
  71. const pickingSessionRef = useRef<PickingSession>()
  72. const accessTokenRef = useRef(accessToken)
  73. const shownPickerRef = useRef(false)
  74. const setAccessToken = useCallback(
  75. (t: string | null) => {
  76. uppy.log('Access token updated')
  77. setAccessTokenStored(t)
  78. accessTokenRef.current = t
  79. },
  80. [setAccessTokenStored, uppy],
  81. )
  82. // keep access token in sync with the ref
  83. useEffect(() => {
  84. accessTokenRef.current = accessToken
  85. }, [accessToken])
  86. const showPicker = useCallback(
  87. async (signal?: AbortSignal) => {
  88. let newAccessToken = accessToken
  89. const doShowPicker = async (token: string) => {
  90. if (pickerType === 'drive') {
  91. await showDrivePicker({ token, apiKey, appId, onFilesPicked, signal })
  92. } else {
  93. // photos
  94. const onPickingSessionChange = (
  95. newPickingSession: PickingSession,
  96. ) => {
  97. pickingSessionRef.current = newPickingSession
  98. }
  99. await showPhotosPicker({
  100. token,
  101. pickingSession: pickingSessionRef.current,
  102. onPickingSessionChange,
  103. signal,
  104. })
  105. }
  106. }
  107. setLoading(true)
  108. try {
  109. try {
  110. await ensureScriptsInjected(pickerType)
  111. if (newAccessToken == null) {
  112. newAccessToken = await authorize({ clientId, pickerType })
  113. }
  114. if (newAccessToken == null) throw new Error()
  115. await doShowPicker(newAccessToken)
  116. shownPickerRef.current = true
  117. setAccessToken(newAccessToken)
  118. } catch (err) {
  119. if (err instanceof InvalidTokenError) {
  120. uppy.log('Token is invalid or expired, reauthenticating')
  121. newAccessToken = await authorize({
  122. pickerType,
  123. accessToken: newAccessToken,
  124. clientId,
  125. })
  126. // now try again:
  127. await doShowPicker(newAccessToken)
  128. shownPickerRef.current = true
  129. setAccessToken(newAccessToken)
  130. } else {
  131. throw err
  132. }
  133. }
  134. } catch (err) {
  135. if (
  136. err instanceof Error &&
  137. 'type' in err &&
  138. err.type === 'popup_closed'
  139. ) {
  140. // user closed the auth popup, ignore
  141. } else {
  142. setAccessToken(null)
  143. uppy.log(err)
  144. }
  145. } finally {
  146. setLoading(false)
  147. }
  148. },
  149. [
  150. accessToken,
  151. apiKey,
  152. appId,
  153. clientId,
  154. onFilesPicked,
  155. pickerType,
  156. setAccessToken,
  157. uppy,
  158. ],
  159. )
  160. useEffect(() => {
  161. const abortController = new AbortController()
  162. pollPickingSession({
  163. pickingSessionRef,
  164. accessTokenRef,
  165. signal: abortController.signal,
  166. onFilesPicked,
  167. onError: (err) => uppy.log(err),
  168. })
  169. return () => abortController.abort()
  170. }, [onFilesPicked, uppy])
  171. useEffect(() => {
  172. // when mounting, once we have a token, be nice to the user and automatically show the picker
  173. // accessToken === undefined means not yet loaded from storage, so wait for that first
  174. if (accessToken === undefined || shownPickerRef.current) {
  175. return undefined
  176. }
  177. const abortController = new AbortController()
  178. showPicker(abortController.signal)
  179. return () => {
  180. // only abort the picker if it's not yet shown
  181. if (!shownPickerRef.current) abortController.abort()
  182. }
  183. }, [accessToken, showPicker])
  184. const handleLogoutClick = useCallback(async () => {
  185. if (accessToken) {
  186. await logout(accessToken)
  187. setAccessToken(null)
  188. pickingSessionRef.current = undefined
  189. }
  190. }, [accessToken, setAccessToken])
  191. if (loading) {
  192. return <div>{uppy.i18n('pleaseWait')}...</div>
  193. }
  194. if (accessToken == null) {
  195. return (
  196. <AuthView
  197. pluginName={
  198. pickerType === 'drive' ?
  199. uppy.i18n('pluginNameGoogleDrive')
  200. : uppy.i18n('pluginNameGooglePhotos')
  201. }
  202. pluginIcon={pickerType === 'drive' ? GoogleDriveIcon : GooglePhotosIcon}
  203. handleAuth={showPicker}
  204. i18n={uppy.i18n}
  205. loading={loading}
  206. />
  207. )
  208. }
  209. return (
  210. <div style={{ textAlign: 'center' }}>
  211. <button
  212. type="button"
  213. className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
  214. style={{ display: 'block', marginBottom: '1em' }}
  215. disabled={loading}
  216. onClick={() => showPicker()}
  217. >
  218. {pickerType === 'drive' ?
  219. uppy.i18n('pickFiles')
  220. : uppy.i18n('pickPhotos')}
  221. </button>
  222. <button
  223. type="button"
  224. className="uppy-u-reset uppy-c-btn"
  225. disabled={loading}
  226. onClick={handleLogoutClick}
  227. >
  228. {uppy.i18n('logOut')}
  229. </button>
  230. </div>
  231. )
  232. }