refactor and fix review feedback

This commit is contained in:
Mikael Finstad 2026-01-27 21:30:23 +08:00
parent d69fff10f5
commit 2601279ecb
No known key found for this signature in database
GPG key ID: 25AB36E3E81CBC26
25 changed files with 585 additions and 630 deletions

View file

@ -11,6 +11,8 @@
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@uppy/core": "workspace:*",
"@uppy/google-drive-picker": "workspace:*",
"@uppy/google-photos-picker": "workspace:*",
"@uppy/react": "workspace:*",
"@uppy/remote-sources": "workspace:*",
"@uppy/screen-capture": "workspace:*",

View file

@ -1,5 +1,6 @@
/** biome-ignore-all lint/nursery/useUniqueElementIds: it's fine */
import Uppy from '@uppy/core'
import GoogleDrivePicker from '@uppy/google-drive-picker'
import {
Dropzone,
FilesGrid,
@ -19,16 +20,36 @@ import Webcam from './Webcam'
import './app.css'
import '@uppy/react/css/style.css'
import GooglePhotosPicker from '@uppy/google-photos-picker'
const companionUrl = 'http://localhost:3020'
const googlePickerClientId = '' // see GOOGLE_PICKER_CLIENT_ID in dev Dashboard
const googlePickerApiKey = '' // see GOOGLE_PICKER_API_KEY in dev Dashboard
const googlePickerAppId = '' // see GOOGLE_PICKER_APP_ID in dev Dashboard
function App() {
const [uppy] = useState(() =>
new Uppy()
new Uppy({
restrictions: {
maxNumberOfFiles: 1,
},
})
.use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
})
.use(GoogleDrivePicker, {
clientId: googlePickerClientId,
companionUrl,
apiKey: googlePickerApiKey,
appId: googlePickerAppId,
})
.use(GooglePhotosPicker, {
clientId: googlePickerClientId,
companionUrl,
})
.use(UppyWebcam)
.use(UppyScreenCapture)
.use(UppyRemoteSources, { companionUrl: 'http://localhost:3020' }),
.use(UppyRemoteSources, { companionUrl }),
)
const dialogRef = useRef<HTMLDialogElement>(null)

View file

@ -11,26 +11,15 @@ export interface CustomDropzoneProps {
openModal: (plugin: CustomDropzonePlugin) => void
}
const companionUrl = 'http://localhost:3020'
const googlePickerClientId = '' // see GOOGLE_PICKER_CLIENT_ID in dev Dashboard
const googlePickerApiKey = '' // see GOOGLE_PICKER_API_KEY in dev Dashboard
const googlePickerAppId = '' // see GOOGLE_PICKER_APP_ID in dev Dashboard
export function CustomDropzone({ openModal }: CustomDropzoneProps) {
const { getRootProps, getInputProps } = useDropzone({ noClick: true })
const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
const googleDrivePicker = useGooglePicker({
clientId: googlePickerClientId,
companionUrl: companionUrl,
apiKey: googlePickerApiKey,
appId: googlePickerAppId,
pickerType: 'drive',
})
const googlePhotosPicker = useGooglePicker({
clientId: googlePickerClientId,
companionUrl: companionUrl,
pickerType: 'photos',
})

View file

@ -41,6 +41,9 @@
"p-retry": "^6.1.0"
},
"devDependencies": {
"@types/gapi": "^0.0.47",
"@types/google.accounts": "^0.0.14",
"@types/google.picker": "^0.0.42",
"jsdom": "^26.1.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"

View file

@ -1,4 +1,14 @@
import type Uppy from '@uppy/core'
export type GooglePickerType = 'drive' | 'photos'
import type { UppyEventMap } from '@uppy/core'
import type { Meta } from '@uppy/utils'
import {
type CompanionPluginOptions,
type RequestClientOptions,
tokenStorage,
} from './index.js'
// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems
// Note that the google api doc is not correct, hence some things are optional here but not in their docs
@ -112,9 +122,7 @@ async function injectScript(src: string) {
injectedScripts.add(src)
}
export async function ensureScriptsInjected(
pickerType: PickerType,
): Promise<void> {
async function ensureScriptsInjected(pickerType: PickerType): Promise<void> {
await Promise.all([
injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services
(async () => {
@ -149,7 +157,7 @@ async function isTokenValid(
return false
}
export async function authorize({
async function authorize({
pickerType,
clientId,
accessToken,
@ -190,7 +198,7 @@ export async function authorize({
return response.access_token
}
export async function logout(accessToken: string): Promise<void> {
async function doLogout(accessToken: string): Promise<void> {
await new Promise<void>((resolve) =>
google.accounts.oauth2.revoke(accessToken, resolve),
)
@ -285,7 +293,7 @@ async function handleDocObjectRecursively({
return items
}
export async function showDrivePicker({
async function showDrivePicker({
token,
apiKey,
appId,
@ -352,7 +360,7 @@ export async function showDrivePicker({
signal?.addEventListener('abort', () => picker.dispose())
}
export async function showPhotosPicker({
async function showPhotosPicker({
token,
pickingSession,
onPickingSessionChange,
@ -486,7 +494,7 @@ async function resolvePickedPhotos({
})
}
export async function pollPickingSession({
async function pollPickingSession({
getPickingSession,
getAccessToken,
onPickingSessionClear,
@ -594,3 +602,238 @@ export const mapPickerFile = (
meta: rest.metadata,
}) as Meta), // dunno how to type this
})
export function createGooglePickerStoreAdapter({
uppy,
getPluginState,
setPluginState,
}: {
uppy: Uppy<any, any>
getPluginState: () => GooglePickerState
setPluginState: (state: Partial<GooglePickerState>) => void
}) {
return {
getSnapshot: () => getPluginState(),
setState: (
updater:
| GooglePickerState
| ((prevState: GooglePickerState) => GooglePickerState),
) => {
const newState =
typeof updater === 'function' ? updater(getPluginState()) : updater
setPluginState(newState)
},
subscribe: (listener: (state: GooglePickerState) => void) => {
const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
_prev,
_next,
patch,
): void => {
const screenCapturePatch = (patch?.plugins?.GoogleDrivePicker ??
patch?.plugins?.GooglePhotosPicker) as GooglePickerState | undefined
if (screenCapturePatch) listener(screenCapturePatch)
}
uppy.on('state-update', onStateUpdate)
return () => uppy.off('state-update', onStateUpdate)
},
}
}
export type GooglePickerState = {
loading: boolean
accessToken: string | null | undefined
}
export type GooglePickerStore = ReturnType<
typeof createGooglePickerStoreAdapter
>
export interface GooglePickerOptions
extends Pick<RequestClientOptions, 'companionUrl'>,
Pick<CompanionPluginOptions, 'storage'> {
pickerType: GooglePickerType
clientId: string
requestClientId: string
apiKey?: string
appId?: string
store: GooglePickerStore
}
export function createGooglePickerController({
uppy,
store,
storage: persistentStore = tokenStorage,
pickerType,
companionUrl,
requestClientId = pickerType === 'drive'
? 'GoogleDrivePicker'
: 'GooglePhotosPicker',
clientId,
apiKey,
appId,
}: {
uppy: Uppy
} & GooglePickerOptions) {
const storageKey = `uppy:google-${pickerType}-picker:accessToken`
let abortController = new AbortController()
let initPromise: Promise<void> | undefined
let pickingSession: PickingSession | undefined
const handleFilesPicked = async (
files: PickedItem[],
accessToken: string,
) => {
uppy.addFiles(
files.map((file) =>
mapPickerFile({ requestClientId, accessToken, companionUrl }, file),
),
)
}
function setAccessToken(newAccessToken: string | null) {
store.setState((s) => ({ ...s, accessToken: newAccessToken }))
if (newAccessToken == null) {
return persistentStore.removeItem(storageKey)
}
return persistentStore.setItem(storageKey, newAccessToken)
}
function setLoading(v: boolean) {
store.setState((s) => ({ ...s, loading: v }))
}
async function init() {
if (initPromise == null) {
initPromise = (async () => {
abortController = new AbortController()
setAccessToken(await persistentStore.getItem(storageKey))
abortController.signal.throwIfAborted()
// For google photos we need to continuously poll the current picking session
if (pickerType === 'photos') {
pollPickingSession({
getPickingSession: () => pickingSession,
getAccessToken: () => store.getSnapshot().accessToken,
onPickingSessionClear: () => {
pickingSession = undefined
},
signal: abortController.signal,
onFilesPicked: handleFilesPicked,
onError: (err) => uppy.log(err),
})
}
})()
}
await initPromise
}
async function showPicker() {
await init()
let newAccessToken = store.getSnapshot().accessToken
const doShowPicker = async (token: string) => {
if (pickerType === 'drive') {
if (apiKey == null || appId == null)
throw new TypeError(
'apiKey and appId are required for Google Drive picker',
)
await showDrivePicker({
token,
apiKey,
appId,
onFilesPicked: handleFilesPicked,
signal: abortController.signal,
onLoadingChange: (isLoading: boolean) => setLoading(isLoading),
onError: (err: unknown) => {
uppy.log(err)
uppy.info(uppy.i18n('failedToAddFiles'), 'error')
},
})
} else {
// photos
const onPickingSessionChange = (newPickingSession: PickingSession) => {
pickingSession = newPickingSession
}
await showPhotosPicker({
token,
pickingSession,
onPickingSessionChange,
signal: abortController.signal,
})
}
}
setLoading(true)
try {
try {
await ensureScriptsInjected(pickerType)
if (newAccessToken == null) {
newAccessToken = await authorize({ clientId, pickerType })
}
if (newAccessToken == null) throw new Error()
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 {
throw err
}
}
} catch (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)
}
}
async function logout() {
const { accessToken } = store.getSnapshot()
if (accessToken) {
await doLogout(accessToken)
setAccessToken(null)
pickingSession = undefined
}
}
function reset() {
abortController.abort()
pickingSession = undefined
initPromise = undefined
store.setState((s) => ({ ...s, accessToken: undefined, loading: false }))
}
return {
init,
show: showPicker,
reset,
logout,
}
}

View file

@ -4,6 +4,25 @@
export type { CompanionPluginOptions } from './CompanionPluginOptions.js'
export { default as getAllowedHosts } from './getAllowedHosts.js'
export type {
GooglePickerOptions,
GooglePickerState,
GooglePickerType,
MediaItem,
MediaItemBase,
PhotoMediaItem,
PickedDriveItem,
PickedItemBase,
PickedPhotosItem,
PickingSession,
UnspecifiedMediaItem,
VideoMediaItem,
} from './googlePicker.js'
export {
createGooglePickerController,
createGooglePickerStoreAdapter,
InvalidTokenError,
} from './googlePicker.js'
export { default as Provider } from './Provider.js'
export {
default as RequestClient,

View file

@ -2,7 +2,8 @@
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"outDir": "./lib",
"rootDir": "./src"
"rootDir": "./src",
"types": ["google.accounts", "google.picker", "gapi"]
},
"include": ["./src/**/*.*"],
"exclude": ["./src/**/*.test.ts"],

View file

@ -2,7 +2,8 @@
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true
"noEmit": true,
"types": ["google.accounts", "google.picker", "gapi"]
},
"include": ["./package.json", "./src/**/*.*"],
"references": [

View file

@ -52,7 +52,10 @@
"typescript": "^5.8.3"
},
"peerDependencies": {
"@uppy/companion-client": "workspace:^",
"@uppy/core": "workspace:^",
"@uppy/google-drive-picker": "workspace:^",
"@uppy/google-photos-picker": "workspace:^",
"@uppy/image-editor": "workspace:^",
"@uppy/screen-capture": "workspace:^",
"@uppy/webcam": "workspace:^"

View file

@ -0,0 +1,53 @@
import {
createGooglePickerStoreAdapter,
type GooglePickerType,
} from '@uppy/companion-client'
import type Uppy from '@uppy/core'
import type GoogleDrivePicker from '@uppy/google-drive-picker'
import type GooglePhotosPicker from '@uppy/google-photos-picker'
export function createGooglePickerPluginAdapter(
uppy: Uppy<any, any>,
pickerType: GooglePickerType,
) {
const pluginName =
pickerType === 'drive' ? 'GoogleDrivePicker' : 'GooglePhotosPicker'
const plugin = uppy.getPlugin<
GoogleDrivePicker<any, any> | GooglePhotosPicker<any, any>
>(pluginName)
if (!plugin) {
throw new Error(`${pluginName} plugin is not registered in Uppy instance`)
}
const { getPluginState, setPluginState, opts } = plugin
const {
companionUrl,
companionCookiesRule,
companionHeaders,
companionKeysParams,
clientId,
} = opts
const apiKey = 'apiKey' in opts ? opts.apiKey : undefined
const appId = 'appId' in opts ? opts.appId : undefined
const { requestClientId } = plugin
return {
store: createGooglePickerStoreAdapter({
uppy,
getPluginState,
setPluginState,
}),
opts: {
companionUrl,
companionCookiesRule,
companionHeaders,
companionKeysParams,
clientId,
apiKey,
appId,
requestClientId,
},
}
}

View file

@ -1,246 +0,0 @@
import type { RequestClientOptions } from '@uppy/companion-client'
import {
type CompanionPluginOptions,
RequestClient,
tokenStorage,
} from '@uppy/companion-client'
import type Uppy from '@uppy/core'
import {
authorize,
logout as doLogout,
ensureScriptsInjected,
InvalidTokenError,
mapPickerFile,
type PickedItem,
type PickingSession,
pollPickingSession,
showDrivePicker,
showPhotosPicker,
} from '@uppy/provider-views'
export type GooglePickerType = 'drive' | 'photos'
/**
* A simple react-like immutable store that can be used with useSyncExternalStore and the like
*/
function createStore<T extends object>(initialState: T) {
let state = initialState
const listeners = new Set<(state: T) => void>()
function getSnapshot() {
return state
}
function setState(updater: T | ((prevState: T) => T)) {
state = typeof updater === 'function' ? updater(state) : updater
listeners.forEach((listener) => listener(state))
}
function subscribe(listener: (state: T) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
return {
getSnapshot,
setState,
subscribe,
}
}
export interface GooglePickerOptions
extends RequestClientOptions,
Pick<CompanionPluginOptions, 'storage'> {
pickerType: GooglePickerType
clientId: string
requestClientId?: string
apiKey?: string
appId?: string
}
export function createGooglePickerController({
uppy,
storage: persistentStore = tokenStorage,
pickerType,
companionUrl,
companionHeaders,
companionCookiesRule,
requestClientId = pickerType === 'drive'
? 'GoogleDrivePicker'
: 'GooglePhotosPicker',
clientId,
apiKey,
appId,
}: {
uppy: Uppy
} & GooglePickerOptions) {
const storageKey = `uppy:google-${pickerType}-picker:accessToken`
const store = createStore({
loading: false,
accessToken: undefined as string | null | undefined,
})
let abortController = new AbortController()
let initPromise: Promise<void> | undefined
let pickingSession: PickingSession | undefined
const handleFilesPicked = async (
files: PickedItem[],
accessToken: string,
) => {
uppy.addFiles(
files.map((file) =>
mapPickerFile({ requestClientId, accessToken, companionUrl }, file),
),
)
}
function setAccessToken(newAccessToken: string | null) {
store.setState((s) => ({ ...s, accessToken: newAccessToken }))
if (newAccessToken == null) {
return persistentStore.removeItem(storageKey)
}
return persistentStore.setItem(storageKey, newAccessToken)
}
function setLoading(v: boolean) {
store.setState((s) => ({ ...s, loading: v }))
}
async function init() {
abortController = new AbortController()
setAccessToken(await persistentStore.getItem(storageKey))
abortController.signal.throwIfAborted()
// For google photos we need to continuously poll the current picking session
if (pickerType === 'photos') {
pollPickingSession({
getPickingSession: () => pickingSession,
getAccessToken: () => store.getSnapshot().accessToken,
onPickingSessionClear: () => {
pickingSession = undefined
},
signal: abortController.signal,
onFilesPicked: handleFilesPicked,
onError: (err) => uppy.log(err),
})
}
}
async function showPicker() {
if (initPromise == null) initPromise = init()
await initPromise
let newAccessToken = store.getSnapshot().accessToken
const doShowPicker = async (token: string) => {
if (pickerType === 'drive') {
if (apiKey == null || appId == null)
throw new TypeError(
'apiKey and appId are required for Google Drive picker',
)
await showDrivePicker({
token,
apiKey,
appId,
onFilesPicked: handleFilesPicked,
signal: abortController.signal,
onLoadingChange: (isLoading: boolean) => setLoading(isLoading),
onError: (err: unknown) => {
uppy.log(err)
uppy.info(uppy.i18n('failedToAddFiles'), 'error')
},
})
} else {
// photos
const onPickingSessionChange = (newPickingSession: PickingSession) => {
pickingSession = newPickingSession
}
await showPhotosPicker({
token,
pickingSession,
onPickingSessionChange,
signal: abortController.signal,
})
}
}
setLoading(true)
try {
try {
const client = new RequestClient(uppy, {
companionUrl,
companionHeaders,
companionCookiesRule,
})
uppy.registerRequestClient(requestClientId, client)
await ensureScriptsInjected(pickerType)
if (newAccessToken == null) {
newAccessToken = await authorize({ clientId, pickerType })
}
if (newAccessToken == null) throw new Error()
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 {
throw err
}
}
} catch (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)
}
}
async function logout() {
const { accessToken } = store.getSnapshot()
if (accessToken) {
await doLogout(accessToken)
setAccessToken(null)
pickingSession = undefined
}
}
function reset() {
abortController.abort()
pickingSession = undefined
initPromise = undefined
store.setState((s) => ({ ...s, accessToken: undefined, loading: false }))
}
return {
store,
show: showPicker,
reset,
logout,
}
}

View file

@ -1,8 +1,15 @@
// Headless components
export {
createGooglePickerController,
type GooglePickerOptions,
type GooglePickerType,
} from '@uppy/companion-client'
export { type DropzoneProps, default as Dropzone } from './Dropzone.js'
export { default as FilesGrid, type FilesGridProps } from './FilesGrid.js'
export { default as FilesList, type FilesListProps } from './FilesList.js'
export { createGooglePickerPluginAdapter } from './googlePicker.js'
// Headless hooks
export {
createDropzone,
@ -14,11 +21,6 @@ export {
type FileInputFunctions,
type FileInputProps,
} from './hooks/file-input.js'
export {
createGooglePickerController,
type GooglePickerOptions,
type GooglePickerType,
} from './hooks/googlePicker.js'
export {
createRemoteSourceController,
type RemoteSourceKeys,

View file

@ -16,6 +16,12 @@
{
"path": "../google-drive-picker/tsconfig.build.json"
},
{
"path": "../google-photos-picker/tsconfig.build.json"
},
{
"path": "../companion-client/tsconfig.build.json"
},
{
"path": "../image-editor/tsconfig.build.json"
},

View file

@ -15,6 +15,12 @@
{
"path": "../google-drive-picker/tsconfig.build.json"
},
{
"path": "../google-photos-picker/tsconfig.build.json"
},
{
"path": "../companion-client/tsconfig.build.json"
},
{
"path": "../image-editor/tsconfig.build.json"
},

View file

@ -1,16 +1,12 @@
import {
type CompanionPluginOptions,
type GooglePickerState,
RequestClient,
tokenStorage,
} from '@uppy/companion-client'
import type { AsyncStore, BaseProviderPlugin, Body, Meta } from '@uppy/core'
import { UIPlugin, type Uppy } from '@uppy/core'
import {
GoogleDriveIcon,
GooglePickerView,
mapPickerFile,
type PickedItem,
} from '@uppy/provider-views'
import { GoogleDriveIcon, GooglePickerView } from '@uppy/provider-views'
import type { LocaleStrings } from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
@ -30,7 +26,7 @@ export type GoogleDrivePickerOptions = CompanionPluginOptions & {
}
export default class GoogleDrivePicker<M extends Meta, B extends Body>
extends UIPlugin<GoogleDrivePickerOptions, M, B>
extends UIPlugin<GoogleDrivePickerOptions, M, B, GooglePickerState>
implements BaseProviderPlugin
{
static VERSION = packageJson.version
@ -45,6 +41,8 @@ export default class GoogleDrivePicker<M extends Meta, B extends Body>
defaultLocale = locale
requestClientId = GoogleDrivePicker.requestClientId
constructor(uppy: Uppy<M, B>, opts: GoogleDrivePickerOptions) {
super(uppy, opts)
this.id = this.opts.id || 'GoogleDrivePicker'
@ -54,13 +52,25 @@ export default class GoogleDrivePicker<M extends Meta, B extends Body>
this.i18nInit()
this.title = this.i18n('pluginNameGoogleDrivePicker')
const client = new RequestClient(uppy, {
this.setPluginState({
loading: false,
accessToken: undefined,
})
this.getPluginState = this.getPluginState.bind(this)
this.setPluginState = this.setPluginState.bind(this)
const requestClient = new RequestClient(uppy, {
companionUrl: this.opts.companionUrl,
companionHeaders: this.opts.companionHeaders,
companionCookiesRule: this.opts.companionCookiesRule,
companionKeysParams: this.opts.companionKeysParams,
})
this.uppy.registerRequestClient(GoogleDrivePicker.requestClientId, client)
this.uppy.registerRequestClient(
GoogleDrivePicker.requestClientId,
requestClient,
)
}
install(): void {
@ -74,26 +84,10 @@ export default class GoogleDrivePicker<M extends Meta, B extends Body>
this.unmount()
}
private handleFilesPicked = async (
files: PickedItem[],
accessToken: string,
) => {
this.uppy.addFiles(
files.map((file) =>
mapPickerFile(
{
requestClientId: GoogleDrivePicker.requestClientId,
accessToken,
companionUrl: this.opts.companionUrl,
},
file,
),
),
)
}
render = () => (
<GooglePickerView
getPluginState={this.getPluginState}
setPluginState={this.setPluginState}
storage={this.storage}
pickerType="drive"
uppy={this.uppy}
@ -101,7 +95,8 @@ export default class GoogleDrivePicker<M extends Meta, B extends Body>
clientId={this.opts.clientId}
apiKey={this.opts.apiKey}
appId={this.opts.appId}
onFilesPicked={this.handleFilesPicked}
requestClientId={GoogleDrivePicker.requestClientId}
companionUrl={this.opts.companionUrl}
/>
)
}

View file

@ -1,16 +1,12 @@
import {
type CompanionPluginOptions,
type GooglePickerState,
RequestClient,
tokenStorage,
} from '@uppy/companion-client'
import type { AsyncStore, BaseProviderPlugin, Body, Meta } from '@uppy/core'
import { UIPlugin, type Uppy } from '@uppy/core'
import {
GooglePhotosIcon,
GooglePickerView,
mapPickerFile,
type PickedItem,
} from '@uppy/provider-views'
import { GooglePhotosIcon, GooglePickerView } from '@uppy/provider-views'
import type { LocaleStrings } from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
@ -28,7 +24,7 @@ export type GooglePhotosPickerOptions = CompanionPluginOptions & {
}
export default class GooglePhotosPicker<M extends Meta, B extends Body>
extends UIPlugin<GooglePhotosPickerOptions, M, B>
extends UIPlugin<GooglePhotosPickerOptions, M, B, GooglePickerState>
implements BaseProviderPlugin
{
static VERSION = packageJson.version
@ -43,6 +39,8 @@ export default class GooglePhotosPicker<M extends Meta, B extends Body>
defaultLocale = locale
requestClientId = GooglePhotosPicker.requestClientId
constructor(uppy: Uppy<M, B>, opts: GooglePhotosPickerOptions) {
super(uppy, opts)
this.id = this.opts.id || 'GooglePhotosPicker'
@ -52,13 +50,25 @@ export default class GooglePhotosPicker<M extends Meta, B extends Body>
this.i18nInit()
this.title = this.i18n('pluginNameGooglePhotosPicker')
const client = new RequestClient(uppy, {
this.setPluginState({
loading: false,
accessToken: undefined,
})
this.getPluginState = this.getPluginState.bind(this)
this.setPluginState = this.setPluginState.bind(this)
const requestClient = new RequestClient(uppy, {
companionUrl: this.opts.companionUrl,
companionHeaders: this.opts.companionHeaders,
companionCookiesRule: this.opts.companionCookiesRule,
companionKeysParams: this.opts.companionKeysParams,
})
this.uppy.registerRequestClient(GooglePhotosPicker.requestClientId, client)
this.uppy.registerRequestClient(
GooglePhotosPicker.requestClientId,
requestClient,
)
}
install(): void {
@ -72,32 +82,17 @@ export default class GooglePhotosPicker<M extends Meta, B extends Body>
this.unmount()
}
private handleFilesPicked = async (
files: PickedItem[],
accessToken: string,
) => {
this.uppy.addFiles(
files.map((file) =>
mapPickerFile(
{
requestClientId: GooglePhotosPicker.requestClientId,
accessToken,
companionUrl: this.opts.companionUrl,
},
file,
),
),
)
}
render = () => (
<GooglePickerView
getPluginState={this.getPluginState}
setPluginState={this.setPluginState}
storage={this.storage}
pickerType="photos"
uppy={this.uppy}
i18n={this.i18n}
clientId={this.opts.clientId}
onFilesPicked={this.handleFilesPicked}
requestClientId={GooglePhotosPicker.requestClientId}
companionUrl={this.opts.companionUrl}
/>
)
}

View file

@ -48,9 +48,6 @@
"preact": "^10.26.10"
},
"devDependencies": {
"@types/gapi": "^0.0.47",
"@types/google.accounts": "^0.0.14",
"@types/google.picker": "^0.0.42",
"cssnano": "^7.0.7",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",

View file

@ -1,50 +1,77 @@
import {
type CompanionPluginOptions,
createGooglePickerController,
createGooglePickerStoreAdapter,
type GooglePickerOptions,
type GooglePickerState,
} from '@uppy/companion-client'
import type { AsyncStore, Uppy } from '@uppy/core'
import type { I18n } from '@uppy/utils'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { useSyncExternalStore } from 'preact/compat'
import { useEffect, useMemo, useRef } from 'preact/hooks'
import AuthView from '../ProviderView/AuthView.js'
import {
authorize,
ensureScriptsInjected,
InvalidTokenError,
logout,
type PickedItem,
type PickingSession,
pollPickingSession,
showDrivePicker,
showPhotosPicker,
} from './googlePicker.js'
import { GoogleDriveIcon, GooglePhotosIcon } from './icons.js'
function useStore(
store: AsyncStore,
key: string,
): [string | undefined | null, (v: string | null) => Promise<void>] {
const [value, setValueState] = useState<string | null | undefined>()
useEffect(() => {
;(async () => {
setValueState(await store.getItem(key))
})()
}, [key, store])
export function useGooglePicker({
uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
storage,
store,
}: GooglePickerOptions & { uppy: Uppy<any, any> } & Pick<
CompanionPluginOptions,
'companionUrl'
>) {
const { subscribe, getSnapshot } = store
const setValue = useCallback(
async (v: string | null) => {
setValueState(v)
if (v == null) {
return store.removeItem(key)
}
return store.setItem(key, v)
},
[key, store],
const { reset, init, ...rest } = useMemo(
() =>
createGooglePickerController({
uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
storage,
store,
}),
[
uppy,
requestClientId,
clientId,
companionUrl,
pickerType,
apiKey,
appId,
storage,
store,
],
)
return [value, setValue]
useEffect(() => {
init()
return () => {
reset()
}
}, [reset, init])
return { ...useSyncExternalStore(subscribe, getSnapshot), ...rest }
}
export type GooglePickerViewProps = {
setPluginState: (state: Partial<GooglePickerState>) => void
getPluginState: () => GooglePickerState
uppy: Uppy<any, any>
i18n: I18n
clientId: string
onFilesPicked: (files: PickedItem[], accessToken: string) => void
requestClientId: string
companionUrl: CompanionPluginOptions['companionUrl']
storage: AsyncStore
} & (
| {
@ -59,179 +86,54 @@ export type GooglePickerViewProps = {
}
)
// todo rewrite to use useGooglePicker hook internally
export default function GooglePickerView({
getPluginState,
setPluginState,
uppy,
i18n,
clientId,
onFilesPicked,
pickerType,
apiKey,
appId,
storage,
requestClientId,
companionUrl,
}: GooglePickerViewProps) {
const [loading, setLoading] = useState(false)
const [accessToken, setAccessTokenStored] = useStore(
const store = useMemo(
() =>
createGooglePickerStoreAdapter({ uppy, getPluginState, setPluginState }),
[uppy, getPluginState, setPluginState],
)
const googlePicker = useGooglePicker({
uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
storage,
`uppy:google-${pickerType}-picker:accessToken`,
)
store,
})
const pickingSessionRef = useRef<PickingSession>()
const accessTokenRef = useRef(accessToken)
const shownPickerRef = useRef(false)
const setAccessToken = useCallback(
(t: string | null) => {
uppy.log('Access token updated')
setAccessTokenStored(t)
accessTokenRef.current = t
},
[setAccessTokenStored, uppy],
)
// keep access token in sync with the ref
useEffect(() => {
accessTokenRef.current = accessToken
}, [accessToken])
const showPicker = useCallback(
async (signal?: AbortSignal) => {
let newAccessToken = accessToken
const doShowPicker = async (token: string) => {
if (pickerType === 'drive') {
await showDrivePicker({
token,
apiKey,
appId,
onFilesPicked,
signal,
onLoadingChange: (isLoading: boolean) => setLoading(isLoading),
onError: (err: unknown) => {
uppy.log(err)
uppy.info(i18n('failedToAddFiles'), 'error')
},
})
} else {
// photos
const onPickingSessionChange = (
newPickingSession: PickingSession,
) => {
pickingSessionRef.current = newPickingSession
}
await showPhotosPicker({
token,
pickingSession: pickingSessionRef.current,
onPickingSessionChange,
signal,
})
}
}
setLoading(true)
try {
try {
await ensureScriptsInjected(pickerType)
if (newAccessToken == null) {
newAccessToken = await authorize({ clientId, pickerType })
}
if (newAccessToken == null) throw new Error()
await doShowPicker(newAccessToken)
shownPickerRef.current = true
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)
shownPickerRef.current = true
setAccessToken(newAccessToken)
} else {
throw err
}
}
} catch (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)
}
},
[
accessToken,
apiKey,
appId,
clientId,
onFilesPicked,
pickerType,
setAccessToken,
uppy,
i18n,
],
)
useEffect(() => {
const abortController = new AbortController()
pollPickingSession({
getPickingSession: () => pickingSessionRef.current,
getAccessToken: () => accessTokenRef.current,
onPickingSessionClear: () => {
pickingSessionRef.current = undefined
},
signal: abortController.signal,
onFilesPicked,
onError: (err) => uppy.log(err),
})
return () => abortController.abort()
}, [onFilesPicked, uppy])
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
// googlePicker.accessToken === undefined means not yet loaded from storage, so wait for that first
if (googlePicker.accessToken === undefined || shownPickerRef.current) {
return
}
shownPickerRef.current = true
googlePicker.show()
}, [googlePicker.show, googlePicker.accessToken])
const abortController = new AbortController()
showPicker(abortController.signal)
return () => {
// only abort the picker if it's not yet shown
if (!shownPickerRef.current) abortController.abort()
}
}, [accessToken, showPicker])
const handleLogoutClick = useCallback(async () => {
if (accessToken) {
await logout(accessToken)
setAccessToken(null)
pickingSessionRef.current = undefined
}
}, [accessToken, setAccessToken])
if (loading) {
if (googlePicker.loading) {
return <div>{i18n('pleaseWait')}...</div>
}
if (accessToken == null) {
if (googlePicker.accessToken == null) {
return (
<AuthView
pluginName={
@ -240,9 +142,9 @@ export default function GooglePickerView({
: i18n('pluginNameGooglePhotosPicker')
}
pluginIcon={pickerType === 'drive' ? GoogleDriveIcon : GooglePhotosIcon}
handleAuth={showPicker}
handleAuth={googlePicker.show}
i18n={i18n}
loading={loading}
loading={googlePicker.loading}
/>
)
}
@ -253,16 +155,16 @@ export default function GooglePickerView({
type="button"
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
style={{ display: 'block', marginBottom: '1em' }}
disabled={loading}
onClick={() => showPicker()}
disabled={googlePicker.loading}
onClick={googlePicker.show}
>
{pickerType === 'drive' ? i18n('pickFiles') : i18n('pickPhotos')}
</button>
<button
type="button"
className="uppy-u-reset uppy-c-btn"
disabled={loading}
onClick={handleLogoutClick}
disabled={googlePicker.loading}
onClick={googlePicker.logout}
>
{i18n('logOut')}
</button>

View file

@ -1,27 +1,5 @@
export { default as FilterInput } from './FilterInput.js'
export { default as GooglePickerView } from './GooglePicker/GooglePickerView.js'
export type {
MediaItem,
MediaItemBase,
PhotoMediaItem,
PickedDriveItem,
PickedItem,
PickedItemBase,
PickedPhotosItem,
PickingSession,
UnspecifiedMediaItem,
VideoMediaItem,
} from './GooglePicker/googlePicker.js'
export {
authorize,
ensureScriptsInjected,
InvalidTokenError,
logout,
mapPickerFile,
pollPickingSession,
showDrivePicker,
showPhotosPicker,
} from './GooglePicker/googlePicker.js'
export { GoogleDriveIcon, GooglePhotosIcon } from './GooglePicker/icons.js'
export {
default as ProviderViews,

View file

@ -2,8 +2,7 @@
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"outDir": "./lib",
"rootDir": "./src",
"types": ["google.accounts", "google.picker", "gapi"]
"rootDir": "./src"
},
"include": ["./src/**/*.*"],
"exclude": ["./src/**/*.test.ts"],

View file

@ -2,8 +2,7 @@
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"types": ["google.accounts", "google.picker", "gapi"]
"noEmit": true
},
"include": ["./package.json", "./src/**/*.*"],
"references": [

View file

@ -1,5 +1,6 @@
import {
createGooglePickerController,
createGooglePickerPluginAdapter,
type GooglePickerOptions,
} from '@uppy/components'
@ -8,56 +9,40 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import { useUppyContext } from './headless/UppyContextProvider.js'
export function useGooglePicker({
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
companionCookiesRule,
companionHeaders,
companionKeysParams,
storage,
}: GooglePickerOptions) {
}: Pick<GooglePickerOptions, 'pickerType' | 'storage'>) {
const { uppy } = useUppyContext()
const {
store: { subscribe, getSnapshot },
reset,
...rest
} = useMemo(
const { store, opts } = useMemo(
() => createGooglePickerPluginAdapter(uppy, pickerType),
[uppy, pickerType],
)
const { subscribe, getSnapshot } = store
const { reset, init, ...rest } = useMemo(
() =>
createGooglePickerController({
uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
companionCookiesRule,
companionHeaders,
companionKeysParams,
storage,
store,
...opts,
}),
[
uppy,
requestClientId,
clientId,
companionUrl,
pickerType,
apiKey,
appId,
companionCookiesRule,
companionHeaders,
companionKeysParams,
storage,
],
[uppy, pickerType, storage, store, opts],
)
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
useEffect(() => {
init()
useEffect(() => () => reset(), [reset])
return () => {
reset()
}
}, [reset, init])
return { ...store, ...rest }
return {
...useSyncExternalStore(subscribe, getSnapshot, getSnapshot),
...rest,
}
}

View file

@ -1,8 +1,9 @@
import {
createGooglePickerController,
type GooglePickerOptions,
} from "@uppy/components";
import { onDestroy } from "svelte";
createGooglePickerController,
createGooglePickerPluginAdapter,
type GooglePickerOptions,
} from '@uppy/components';
import { onDestroy, onMount } from "svelte";
import { getUppyContext } from "./components/headless/uppyContext.js";
import { useExternalStore } from "./useSyncExternalStore.svelte.js";
@ -17,34 +18,34 @@ type SvelteGooglePickerSnapshot = GooglePickerSnapshot & {
};
export function useGooglePicker({
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
...restOptions
}: GooglePickerOptions): SvelteGooglePickerSnapshot {
storage,
}: Pick<GooglePickerOptions, 'pickerType' | 'storage'>): SvelteGooglePickerSnapshot {
const ctx = getUppyContext();
const { store, opts } = createGooglePickerPluginAdapter(ctx.uppy, pickerType)
const { subscribe, getSnapshot } = store;
const {
store: { subscribe, getSnapshot },
reset,
init,
show,
logout,
} = createGooglePickerController({
uppy: ctx.uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
...restOptions,
store,
storage,
...opts,
});
const state = useExternalStore<GooglePickerSnapshot>(getSnapshot, subscribe);
onMount(() => {
init()
});
onDestroy(() => {
reset();
});

View file

@ -1,8 +1,9 @@
import {
createGooglePickerController,
createGooglePickerPluginAdapter,
type GooglePickerOptions,
} from '@uppy/components'
import { computed, onUnmounted, type ShallowRef } from 'vue'
import { computed, onMounted, onUnmounted, type ShallowRef } from 'vue'
import { injectUppyContext } from './headless/context-provider.js'
import { useExternalStore } from './useSyncExternalStore.js'
@ -17,36 +18,31 @@ type GooglePickerController = {
}
export function useGooglePicker({
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
...restOptions
}: GooglePickerOptions): ShallowRef<
storage,
}: Pick<GooglePickerOptions, 'pickerType' | 'storage'>): ShallowRef<
GooglePickerSnapshot & GooglePickerController
> {
const ctx = injectUppyContext()
const {
store: { subscribe, getSnapshot },
reset,
show,
logout,
} = createGooglePickerController({
const { store, opts } = createGooglePickerPluginAdapter(ctx.uppy, pickerType)
const { subscribe, getSnapshot } = store
const { reset, init, show, logout } = createGooglePickerController({
uppy: ctx.uppy,
requestClientId,
companionUrl,
pickerType,
clientId,
apiKey,
appId,
...restOptions,
store,
storage,
...opts,
})
const state = useExternalStore<GooglePickerSnapshot>(getSnapshot, subscribe)
onMounted(() => {
init()
})
onUnmounted(() => {
reset()
})

View file

@ -8787,6 +8787,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@uppy/companion-client@workspace:packages/@uppy/companion-client"
dependencies:
"@types/gapi": "npm:^0.0.47"
"@types/google.accounts": "npm:^0.0.14"
"@types/google.picker": "npm:^0.0.42"
"@uppy/utils": "workspace:^"
jsdom: "npm:^26.1.0"
namespace-emitter: "npm:^2.0.1"
@ -8881,7 +8884,10 @@ __metadata:
tailwindcss: "npm:^4.0.6"
typescript: "npm:^5.8.3"
peerDependencies:
"@uppy/companion-client": "workspace:^"
"@uppy/core": "workspace:^"
"@uppy/google-drive-picker": "workspace:^"
"@uppy/google-photos-picker": "workspace:^"
"@uppy/image-editor": "workspace:^"
"@uppy/screen-capture": "workspace:^"
"@uppy/webcam": "workspace:^"
@ -9185,9 +9191,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "@uppy/provider-views@workspace:packages/@uppy/provider-views"
dependencies:
"@types/gapi": "npm:^0.0.47"
"@types/google.accounts": "npm:^0.0.14"
"@types/google.picker": "npm:^0.0.42"
"@uppy/utils": "workspace:^"
classnames: "npm:^2.2.6"
cssnano: "npm:^7.0.7"
@ -13257,6 +13260,8 @@ __metadata:
"@types/react": "npm:^19.0.10"
"@types/react-dom": "npm:^19.0.4"
"@uppy/core": "workspace:*"
"@uppy/google-drive-picker": "workspace:*"
"@uppy/google-photos-picker": "workspace:*"
"@uppy/react": "workspace:*"
"@uppy/remote-sources": "workspace:*"
"@uppy/screen-capture": "workspace:*"