diff --git a/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx index cb8864fab0..e2313710eb 100644 --- a/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx +++ b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx @@ -7,11 +7,8 @@ import { tokenStorage, } from '@uppy/companion-client' +import type { PickedItem } from '@uppy/provider-views/lib/GooglePicker/googlePicker.js' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import { - type PickedItem, - type PluginState, -} from '@uppy/provider-views/lib/GooglePicker/GooglePickerView.js' import type { AsyncStore, BaseProviderPlugin } from '@uppy/core/lib/Uppy.js' // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -66,7 +63,7 @@ export default class GoogleDrivePicker< M extends Meta & { width: number; height: number }, B extends Body, > - extends UIPlugin + extends UIPlugin implements BaseProviderPlugin { static VERSION = packageJson.version @@ -144,7 +141,6 @@ export default class GoogleDrivePicker< - extends UIPlugin + extends UIPlugin implements BaseProviderPlugin { static VERSION = packageJson.version @@ -137,7 +134,6 @@ export default class GooglePhotosPicker< ({ - authorization: `Bearer ${token}`, -}) - -const injectedScripts = new Set() - -// https://stackoverflow.com/a/39008859/6519037 -async function injectScript(src: string) { - if (injectedScripts.has(src)) return - - await new Promise((resolve, reject) => { - const script = document.createElement('script') - script.src = src - script.addEventListener('load', () => resolve()) - script.addEventListener('error', (e) => reject(e.error)) - document.head.appendChild(script) - }) - injectedScripts.add(src) -} +import type { Uppy } from '@uppy/core' +import useStore from '@uppy/core/lib/useStore.js' +import type { AsyncStore } from '@uppy/core/lib/Uppy.js' -async function isTokenValid(accessToken: string): Promise { - try { - const response = await fetch( - `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, - ) - if (response.ok) { - await response.json() - return true - } - console.warn( - 'Token is invalid or expired:', - response.status, - await response.text(), - ) - // Token is invalid or expired - return false - } catch (error) { - console.error('Error checking token validity:', error) - return false - } -} +import { + authorize, + ensureScriptsInjected, + InvalidTokenError, + logout, + pollPickingSession, + showDrivePicker, + showPhotosPicker, + type PickedItem, + type PickingSession, +} from './googlePicker.js' export type GooglePickerViewProps = { - plugin: UIPlugin uppy: Uppy clientId: string onFilesPicked: (files: PickedItem[], accessToken: string) => void @@ -156,342 +40,156 @@ export default function GooglePickerView({ uppy, clientId, onFilesPicked, - plugin, pickerType, apiKey, appId, storage, }: GooglePickerViewProps) { - const [{ scriptsLoaded }, setPluginState] = useUppyPluginState(plugin) const [loading, setLoading] = useState(false) - const [signedOut, setSignedOut] = useState(false) - const [accessToken, setAccessToken] = useStore( + const [accessToken, setAccessTokenStored] = useStore( storage, `uppy:google-${pickerType}-picker:accessToken`, ) - const onPicked = useCallback( - async (picked: google.picker.ResponseObject) => { - if (picked.action === google.picker.Action.PICKED) { - // console.log('Picker response', JSON.stringify(picked, null, 2)); - if (accessToken == null) throw new Error() - onFilesPicked( - picked['docs'].map((doc) => ({ - platform: 'drive', - id: doc['id'], - name: doc['name'], - mimeType: doc['mimeType'], - })), - accessToken, - ) - } - }, - [accessToken, onFilesPicked], - ) + const pickingSessionRef = useRef() + const accessTokenRef = useRef(accessToken) + const shownPickerRef = useRef(false) - const showDrivePicker = useCallback( - (token: string) => { - if (pickerType !== 'drive') throw new Error() - const picker = new google.picker.PickerBuilder() - .enableFeature(google.picker.Feature.NAV_HIDDEN) - .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) - .setDeveloperKey(apiKey) - .setAppId(appId) - .setOAuthToken(token) - .addView( - new google.picker.DocsView(google.picker.ViewId.DOCS) - .setIncludeFolders(true) - // Note: setEnableDrives doesn't seem to work - // .setEnableDrives(true) - .setSelectFolderEnabled(false), - ) - // NOTE: photos is broken and results in an error being returned from Google - // .addView(google.picker.ViewId.PHOTOS) - .setCallback(onPicked) - .build() - - picker.setVisible(true) + const setAccessToken = useCallback( + (t: string | null) => { + uppy.log('Access token updated') + setAccessTokenStored(t) + accessTokenRef.current = t }, - [apiKey, appId, onPicked, pickerType], + [setAccessTokenStored, uppy], ) - const pollStartTimeRef = useRef() - const [pickingSession, setPickingSession] = useState() - - const showPhotosPicker = useCallback( - async (token: string) => { - // https://developers.google.com/photos/picker/guides/get-started-picker - try { - setLoading(true) - - const headers = getAuthHeader(token) - - let newPickingSession = pickingSession - if (newPickingSession == null) { - const createSessionResponse = await fetch( - 'https://photospicker.googleapis.com/v1/sessions', - { method: 'post', headers }, - ) - - if (createSessionResponse.status === 401) { - const resp = await createSessionResponse.json() - if (resp.error?.status === 'UNAUTHENTICATED') { - setAccessToken(null) - setSignedOut(true) - return - } + // keep access token in sync with the ref + useEffect(() => { + accessTokenRef.current = accessToken + }, [accessToken]) + + const showPicker = useCallback( + async (signal?: AbortSignal) => { + shownPickerRef.current = true + let newAccessToken = accessToken + + const doShowPicker = async (token: string) => { + if (pickerType === 'drive') { + await showDrivePicker({ token, apiKey, appId, onFilesPicked, signal }) + } else { + // photos + const onPickingSessionChange = ( + newPickingSession: PickingSession, + ) => { + pickingSessionRef.current = newPickingSession } - - if (!createSessionResponse.ok) - throw new Error('Failed to create a session') - newPickingSession = - (await createSessionResponse.json()) as PickingSession - pollStartTimeRef.current = Date.now() - setPickingSession(newPickingSession) + await showPhotosPicker({ + token, + pickingSession: pickingSessionRef.current, + onPickingSessionChange, + signal, + }) } - - window.open(newPickingSession.pickerUri) - } finally { - setLoading(false) } - }, - [pickingSession, setAccessToken], - ) - const authorize = useCallback(async () => { - setSignedOut(false) - setLoading(true) - try { - const response = await new Promise( - (resolve, reject) => { - const scopes = - pickerType === 'drive' ? - ['https://www.googleapis.com/auth/drive.readonly'] - : [ - 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly', - ] + setLoading(true) + try { + try { + await ensureScriptsInjected(pickerType) - const tokenClient = google.accounts.oauth2.initTokenClient({ - client_id: clientId, - // Authorization scopes required by the API; multiple scopes can be included, separated by spaces. - scope: scopes.join(' '), - callback: resolve, - error_callback: reject, - }) + if (newAccessToken == null) { + newAccessToken = await authorize({ clientId, pickerType }) + } + if (newAccessToken == null) throw new Error() - if (accessToken === null) { - // Prompt the user to select a Google Account and ask for consent to share their data - // when establishing a new session. - tokenClient.requestAccessToken({ prompt: 'consent' }) + await doShowPicker(newAccessToken) + setAccessToken(newAccessToken) + } catch (err) { + if (err instanceof InvalidTokenError) { + uppy.log('Token is invalid or expired, reauthenticating') + newAccessToken = await authorize({ + pickerType, + accessToken: newAccessToken, + clientId, + }) + // now try again: + await doShowPicker(newAccessToken) + setAccessToken(newAccessToken) } else { - // Skip display of account chooser and consent dialog for an existing session. - tokenClient.requestAccessToken({ prompt: '' }) + throw err } - }, - ) - if (response.error) { - throw new Error(`OAuth2 error: ${response.error}`) - } - const { access_token: newAccessToken } = response - setAccessToken(newAccessToken) - - // showDrivePicker(newAccessToken); - } catch (err) { - uppy.log(err) - } finally { - setLoading(false) - } - }, [accessToken, clientId, pickerType, setAccessToken, uppy]) - - useEffect(() => { - ;(async () => { - try { - await Promise.all([ - injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services - (async () => { - await injectScript('https://apis.google.com/js/api.js') - - if (pickerType === 'drive') { - await new Promise((resolve) => - gapi.load('client:picker', () => resolve()), - ) - await gapi.client.load( - 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', - ) - } - - setPluginState({ scriptsLoaded: true }) - })(), - ]) + } } catch (err) { - uppy.log(err) + if ( + err instanceof Error && + 'type' in err && + err.type === 'popup_closed' + ) { + // user closed the auth popup, ignore + } else { + setAccessToken(null) + uppy.log(err) + } + } finally { + setLoading(false) } - })() - }, [pickerType, setPluginState, uppy]) - - const showPicker = useCallback(async () => { - if (accessToken === undefined) return // not yet loaded - if (accessToken === null) { - authorize() - return - } - // google drive picker will crash hard if given an invalid token, so we need to check it first - // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265 - if (!(await isTokenValid(accessToken))) { - authorize() - return - } - if (pickerType === 'drive') { - showDrivePicker(accessToken) - } else { - showPhotosPicker(accessToken) - } - }, [accessToken, authorize, pickerType, showDrivePicker, showPhotosPicker]) - - // eslint-disable-next-line no-shadow - const handlePhotosPicked = useCallback( - async (params: { accessToken: string; pickingSession: PickingSession }) => { - const headers = getAuthHeader(params.accessToken) - - let pageToken: string | undefined - let mediaItems: MediaItem[] = [] - do { - const pageSize = 100 - const response = await fetch( - `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: params.pickingSession.id, pageSize: String(pageSize) }).toString()}`, - { headers }, - ) - if (!response.ok) throw new Error('Failed to get a media items') - const { - mediaItems: batchMediaItems, - nextPageToken, - }: { mediaItems: MediaItem[]; nextPageToken?: string } = - await response.json() - pageToken = nextPageToken - mediaItems.push(...batchMediaItems) - } while (pageToken) - - // todo show alert instead about invalid picked files? - mediaItems = mediaItems.flatMap((i) => - ( - i.type === 'PHOTO' || - (i.type === 'VIDEO' && - i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === - 'READY') - ) ? - [i] - : [], - ) - - onFilesPicked( - mediaItems.map( - ({ - id, - // we want the original resolution, so we don't append any parameter to the baseUrl - // https://developers.google.com/photos/library/guides/access-media-items#base-urls - mediaFile: { mimeType, filename, baseUrl }, - }) => ({ - platform: 'photos', - id, - mimeType, - url: baseUrl, - name: filename, - }), - ), - params.accessToken, - ) }, - [onFilesPicked], + [ + accessToken, + apiKey, + appId, + clientId, + onFilesPicked, + pickerType, + setAccessToken, + uppy, + ], ) useEffect(() => { - // if we have a session, poll it until it either times out, or the user selects some photos - // note that the user can also just close the page, but we get no indication of that from Google when polling, - // so we just have to continue polling, in case the user opens the photo selector again - if (pickingSession == null || accessToken == null) return undefined - const abortController = new AbortController() - const headers = getAuthHeader(accessToken) + pollPickingSession({ + pickingSessionRef, + accessTokenRef, + signal: abortController.signal, + onFilesPicked, + onError: (err) => uppy.log(err), + }) - ;(async () => { - // poll session for user response - for (;;) { - try { - const interval = parseFloat(pickingSession.pollingConfig.pollInterval) + return () => abortController.abort() + }, [onFilesPicked, uppy]) - await Promise.race([ - new Promise((resolve) => setTimeout(resolve, interval * 1000)), - new Promise((_resolve, reject) => { - abortController.signal.onabort = reject - }), - ]) + useEffect(() => { + // when mounting, once we have a token, be nice to the user and automatically show the picker + // accessToken === undefined means not yet loaded from storage, so wait for that first + if (accessToken === undefined || shownPickerRef.current) { + return undefined + } - abortController.signal.throwIfAborted() + const abortController = new AbortController() - // https://developers.google.com/photos/picker/reference/rest/v1/sessions - const response = await fetch( - `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`, - { headers }, - ) - if (!response.ok) throw new Error('Failed to get session') - const json: PickingSession = await response.json() - if (json.mediaItemsSet) { - // console.log('User picked!', json) - pollStartTimeRef.current = undefined - setPickingSession(undefined) - handlePhotosPicked({ accessToken, pickingSession }) - return - } - if (pickingSession.pollingConfig.timeoutIn === '0s') { - uppy.log('Picking session timeout') - pollStartTimeRef.current = undefined - setPickingSession(undefined) - return - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return - } - uppy.log(err) - } - } - })() + showPicker(abortController.signal) return () => abortController.abort() - }, [accessToken, handlePhotosPicked, pickingSession, uppy]) - - useEffect(() => { - if (!scriptsLoaded || signedOut) { - return - } - showPicker() - }, [scriptsLoaded, showPicker, signedOut]) + }, [accessToken, showPicker]) - const handleSignoutClick = useCallback(async () => { - if (accessToken == null) return + const handleLogoutClick = useCallback(async () => { if (accessToken) { - await new Promise((resolve) => - google.accounts.oauth2.revoke(accessToken, resolve), - ) + await logout(accessToken) setAccessToken(null) - setPickingSession(undefined) - setSignedOut(true) // if user signs out, don't re-authenticate automatically + pickingSessionRef.current = undefined } }, [accessToken, setAccessToken]) - if (!scriptsLoaded) { - return null - } - - // for photos, we will never go out of the loading/polling state if (loading) { return
{uppy.i18n('pleaseWait')}...
} if (accessToken == null) { return ( - ) @@ -499,12 +197,12 @@ export default function GooglePickerView({ return ( <> - - diff --git a/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts b/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts new file mode 100644 index 0000000000..53fcaf9069 --- /dev/null +++ b/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts @@ -0,0 +1,425 @@ +import { type MutableRef } from 'preact/hooks' + +// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems +export interface MediaItemBase { + id: string + createTime: string +} + +interface MediaFileMetadataBase { + width: number + height: number + cameraMake: string + cameraModel: string +} + +interface MediaFileBase { + baseUrl: string + mimeType: string + filename: string +} + +export interface VideoMediaItem extends MediaItemBase { + type: 'VIDEO' + mediaFile: MediaFileBase & { + mediaFileMetadata: MediaFileMetadataBase & { + videoMetadata: { + fps: number + processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED' + } + } + } +} + +export interface PhotoMediaItem extends MediaItemBase { + type: 'PHOTO' + mediaFile: MediaFileBase & { + mediaFileMetadata: MediaFileMetadataBase & { + photoMetadata: { + focalLength: number + apertureFNumber: number + isoEquivalent: number + exposureTime: string + } + } + } +} + +export interface UnspecifiedMediaItem extends MediaItemBase { + type: 'TYPE_UNSPECIFIED' + mediaFile: MediaFileBase +} + +export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem + +// https://developers.google.com/photos/picker/reference/rest/v1/sessions +export interface PickingSession { + id: string + pickerUri: string + pollingConfig: { + pollInterval: string + timeoutIn: string + } + expireTime: string + mediaItemsSet: boolean +} + +export interface PickedItemBase { + id: string + mimeType: string + name: string +} + +export interface PickedDriveItem extends PickedItemBase { + platform: 'drive' +} + +export interface PickedPhotosItem extends PickedItemBase { + platform: 'photos' + url: string +} + +export type PickedItem = PickedPhotosItem | PickedDriveItem + +type PickerType = 'drive' | 'photos' + +const getAuthHeader = (token: string) => ({ + authorization: `Bearer ${token}`, +}) + +const injectedScripts = new Set() +let driveApiLoaded = false + +// https://stackoverflow.com/a/39008859/6519037 +async function injectScript(src: string) { + if (injectedScripts.has(src)) return + + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = src + script.addEventListener('load', () => resolve()) + script.addEventListener('error', (e) => reject(e.error)) + document.head.appendChild(script) + }) + injectedScripts.add(src) +} + +export async function ensureScriptsInjected( + pickerType: PickerType, +): Promise { + await Promise.all([ + injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services + (async () => { + await injectScript('https://apis.google.com/js/api.js') + + if (pickerType === 'drive' && !driveApiLoaded) { + await new Promise((resolve) => + gapi.load('client:picker', () => resolve()), + ) + await gapi.client.load( + 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', + ) + driveApiLoaded = true + } + })(), + ]) +} + +async function isTokenValid( + accessToken: string, + signal: AbortSignal | undefined, +) { + const response = await fetch( + `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, + { signal }, + ) + if (response.ok) { + return true + } + // console.warn('Token is invalid or expired:', response.status, await response.text()); + // Token is invalid or expired + return false +} + +export async function authorize({ + pickerType, + clientId, + accessToken, +}: { + pickerType: PickerType + clientId: string + accessToken?: string | null | undefined +}): Promise { + const response = await new Promise( + (resolve, reject) => { + const scopes = + pickerType === 'drive' ? + ['https://www.googleapis.com/auth/drive.readonly'] + : ['https://www.googleapis.com/auth/photospicker.mediaitems.readonly'] + + const tokenClient = google.accounts.oauth2.initTokenClient({ + client_id: clientId, + // Authorization scopes required by the API; multiple scopes can be included, separated by spaces. + scope: scopes.join(' '), + callback: resolve, + error_callback: reject, + }) + + if (accessToken === null) { + // Prompt the user to select a Google Account and ask for consent to share their data + // when establishing a new session. + tokenClient.requestAccessToken({ prompt: 'consent' }) + } else { + // Skip display of account chooser and consent dialog for an existing session. + tokenClient.requestAccessToken({ prompt: '' }) + } + }, + ) + + if (response.error) { + throw new Error(`OAuth2 error: ${response.error}`) + } + return response.access_token +} + +export async function logout(accessToken: string): Promise { + await new Promise((resolve) => + google.accounts.oauth2.revoke(accessToken, resolve), + ) +} + +export class InvalidTokenError extends Error { + constructor() { + super('Invalid or expired token') + this.name = 'InvalidTokenError' + } +} + +export async function showDrivePicker({ + token, + apiKey, + appId, + onFilesPicked, + signal, +}: { + token: string + apiKey: string + appId: string + onFilesPicked: (files: PickedItem[], accessToken: string) => void + signal: AbortSignal | undefined +}): Promise { + // google drive picker will crash hard if given an invalid token, so we need to check it first + // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265 + if (!(await isTokenValid(token, signal))) { + throw new InvalidTokenError() + } + + const onPicked = (picked: google.picker.ResponseObject) => { + if (picked.action === google.picker.Action.PICKED) { + // console.log('Picker response', JSON.stringify(picked, null, 2)); + onFilesPicked( + picked['docs'].map((doc) => ({ + platform: 'drive', + id: doc['id'], + name: doc['name'], + mimeType: doc['mimeType'], + })), + token, + ) + } + } + + const picker = new google.picker.PickerBuilder() + .enableFeature(google.picker.Feature.NAV_HIDDEN) + .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) + .setDeveloperKey(apiKey) + .setAppId(appId) + .setOAuthToken(token) + .addView( + new google.picker.DocsView(google.picker.ViewId.DOCS) + .setIncludeFolders(true) + // Note: setEnableDrives doesn't seem to work + // .setEnableDrives(true) + .setSelectFolderEnabled(false), + ) + // NOTE: photos is broken and results in an error being returned from Google + // I think it's the old Picasa photos + // .addView(google.picker.ViewId.PHOTOS) + .setCallback(onPicked) + .build() + + picker.setVisible(true) + signal?.addEventListener('abort', () => picker.dispose()) +} + +export async function showPhotosPicker({ + token, + pickingSession, + onPickingSessionChange, + signal, +}: { + token: string + pickingSession: PickingSession | undefined + onPickingSessionChange: (ps: PickingSession) => void + signal: AbortSignal | undefined +}): Promise { + // https://developers.google.com/photos/picker/guides/get-started-picker + const headers = getAuthHeader(token) + + let newPickingSession = pickingSession + if (newPickingSession == null) { + const createSessionResponse = await fetch( + 'https://photospicker.googleapis.com/v1/sessions', + { method: 'post', headers, signal }, + ) + + if (createSessionResponse.status === 401) { + const resp = await createSessionResponse.json() + if (resp.error?.status === 'UNAUTHENTICATED') { + throw new InvalidTokenError() + } + } + + if (!createSessionResponse.ok) { + throw new Error('Failed to create a session') + } + newPickingSession = (await createSessionResponse.json()) as PickingSession + + onPickingSessionChange(newPickingSession) + } + + const w = window.open(newPickingSession.pickerUri) + signal?.addEventListener('abort', () => w?.close()) +} + +async function resolvePickedPhotos({ + accessToken, + pickingSession, + signal, +}: { + accessToken: string + pickingSession: PickingSession + signal: AbortSignal +}) { + const headers = getAuthHeader(accessToken) + + let pageToken: string | undefined + let mediaItems: MediaItem[] = [] + do { + const pageSize = 100 + const response = await fetch( + `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: pickingSession.id, pageSize: String(pageSize) }).toString()}`, + { headers, signal }, + ) + if (!response.ok) throw new Error('Failed to get a media items') + const { + mediaItems: batchMediaItems, + nextPageToken, + }: { mediaItems: MediaItem[]; nextPageToken?: string } = + await response.json() + pageToken = nextPageToken + mediaItems.push(...batchMediaItems) + } while (pageToken) + + // todo show alert instead about invalid picked files? + mediaItems = mediaItems.flatMap((i) => + ( + i.type === 'PHOTO' || + (i.type === 'VIDEO' && + i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === + 'READY') + ) ? + [i] + : [], + ) + + return mediaItems.map( + ({ + id, + // we want the original resolution, so we don't append any parameter to the baseUrl + // https://developers.google.com/photos/library/guides/access-media-items#base-urls + mediaFile: { mimeType, filename, baseUrl }, + }) => ({ + platform: 'photos' as const, + id, + mimeType, + url: baseUrl, + name: filename, + }), + ) +} + +export async function pollPickingSession({ + pickingSessionRef, + accessTokenRef, + signal, + onFilesPicked, + onError, +}: { + pickingSessionRef: MutableRef + accessTokenRef: MutableRef + signal: AbortSignal + onFilesPicked: (files: PickedItem[], accessToken: string) => void + onError: (err: unknown) => void +}): Promise { + // if we have an active session, poll it until it either times out, or the user selects some photos. + // Note that the user can also just close the page, but we get no indication of that from Google when polling, + // so we just have to continue polling in the background, so we can react to it + // in case the user opens the photo selector again. Hence the infinite for loop + for (let interval = 1; ; ) { + try { + if (pickingSessionRef.current != null) { + interval = parseFloat( + pickingSessionRef.current.pollingConfig.pollInterval, + ) + } else { + interval = 1 + } + + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, interval * 1000)), + new Promise((_resolve, reject) => { + signal.addEventListener('abort', reject) + }), + ]) + + signal.throwIfAborted() + + const accessToken = accessTokenRef.current + const pickingSession = pickingSessionRef.current + + if (pickingSession != null && accessToken != null) { + const headers = getAuthHeader(accessToken) + + // https://developers.google.com/photos/picker/reference/rest/v1/sessions + const response = await fetch( + `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`, + { headers, signal }, + ) + if (!response.ok) throw new Error('Failed to get session') + const json: PickingSession = await response.json() + if (json.mediaItemsSet) { + // console.log('User picked!', json) + const resolvedPhotos = await resolvePickedPhotos({ + accessToken, + pickingSession, + signal, + }) + // eslint-disable-next-line no-param-reassign + pickingSessionRef.current = undefined + onFilesPicked(resolvedPhotos, accessToken) + } + if (pickingSession.pollingConfig.timeoutIn === '0s') { + // eslint-disable-next-line no-param-reassign + pickingSessionRef.current = undefined + } + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return + } + // just report the error and continue polling + onError(err) + } + } +}