Back

100% Statements 176/176
92.3% Branches 84/91
98.24% Functions 56/57
100% Lines 159/159

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603                                                                                                                                                                5x                                                                                   1053x 4x             1053x                   47x 47x 47x 47x                     702x                     179x 151x   179x 40x 1x   1x   40x 40x 139x 39x 39x                             146x 146x 146x 146x 6x   140x 140x 158x               41x 41x 2x   39x 39x 39x 39x                   51x                                 2x                         7x 7x 7x                               4x                   173x                   173x 173x           173x         173x               173x         173x                   173x 50x 50x 50x 50x 56x   50x     173x 34x 28x 9x   19x                 173x 27x             173x   28x 28x 28x 28x 28x 28x             173x   7x 7x       173x                   173x   5x 5x 1x   4x 1x 1x   3x 3x 4x       15x       15x 15x 1x   14x 1x 1x           13x 13x 17x             13x 13x     10x 10x 1x   9x 1x 1x   8x 8x 7x 1x 1x           8x     8x 8x 10x         8x       173x           173x 85x 47x   38x       38x 38x 2x   38x 1x   37x 37x 37x   37x 1x       173x   51x 51x                             51x 39x   51x                   173x 2x 2x 2x                                     173x 11x 9x 1x   8x 8x 8x   2x 1x   7x 14x 7x 3x       173x   3x 3x 1x   2x 2x 2x               2x 2x           173x                
// Adapter: wraps the legacy single-file resumable (tus) uploader from
// `../../client/hooks/use-resumable-uploader` and exposes a multi-item queue
// suitable for the modernised VideoPress dashboard.
//
// Legacy hook shape (as of reading use-resumable-uploader/index.ts):
//   useResumableUploader({ onProgress, onSuccess, onError })
//     onProgress( bytesSent: number, bytesTotal: number ) — raw bytes, no id
//     onSuccess( data: VideoMediaProps )                  — media object, no id
//     onError( err )                                      — error value, no id
//   returns { uploadHandler( file ), resumeHandler: { start, abort } | undefined, … }
//
// The legacy hook is designed around one active upload at a time. It manages
// its own internal state (uploadingData, media, error) and exposes a tus
// `resumeHandler` that is set asynchronously after the first uploadHandler call.
//
// Adapter strategy:
//   - We call useResumableUploader once per useUpload instance.
//   - The upload *queue* lives in a window-attached singleton store (mirrors
//     the QueryClientWrapper pattern) so multiple useUpload() consumers —
//     Library Stage that produces uploads and useFreeTier() that only
//     observes them — see the same items even when they sit in separately
//     code-split route bundles. Subscribers re-render via useSyncExternalStore.
//   - A per-instance `currentIdRef` ref tracks which queue item is being
//     handled by *this* instance's legacy uploader. Only the instance that
//     called startUpload owns the active upload; observer instances stay idle.
//   - startUpload appends to the queue; if the local instance is idle it
//     dispatches immediately, otherwise the item waits and is picked up
//     when the current upload settles (in onSuccess / onError).
//   - The store also holds a per-id session map and a tombstone set. The
//     legacy callbacks carry no identity, so those two are the only way to
//     tell a live session's events from an orphaned one's — see
//     `cancelUpload` and the resume-handle binding effect.
 
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useSyncExternalStore } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import useResumableUploader from '../../client/hooks/use-resumable-uploader';
import { markFirstPublish } from './use-first-run-state';
import { LIBRARY_QUERY_KEY } from './use-library';
import type { VideoMediaProps } from '../../client/lib/resumable-file-uploader/types';
import type { VideoDetailsFormValues } from '../components/video-details/use-video-details-form';
 
export type UploadStatus = 'pending' | 'uploading' | 'success' | 'failed';
 
export type UploadItem = {
	id: string;
	file: File;
	progress: number; // 0..1
	status: UploadStatus;
	error?: string;
	errorCode?: string;
	/**
	 * The created video, present once `status` is `success`. This is what
	 * lets anything downstream — the upload pill, the upload→edit
	 * transition — link to `/video/:id` for a finished upload.
	 */
	media?: VideoMediaProps;
	/**
	 * Which flow started this upload (e.g. 'onboarding'). The queue outlives
	 * any component — the first upload on a first-run site flips the tab
	 * order mid-flight and remounts the flow that started it — so the flow
	 * re-finds its own items by this tag rather than holding the only
	 * pointer in remountable state.
	 */
	context?: string;
	/**
	 * ISO timestamp stamped once at enqueue. Anything that needs a date for a
	 * row that has no server record yet — the Library's optimistic splice, a
	 * pill row — reads this, so the date doesn't move on every re-render.
	 */
	enqueuedAt: string;
	/**
	 * Dirty-field diff written through from the edit session, hydrated by
	 * `/video/:id` on arrival. Form state lives inside the editor, which the
	 * upload step cannot read at navigate time, so the draft travels on the
	 * row rather than being stashed at the handoff.
	 */
	draft?: Partial< VideoDetailsFormValues >;
};
 
const STORE_KEY = '__jetpackVideopressUploadStore' as const;
 
type UploadSession = {
	/** Bound once tus hands the handle over — absent during the JWT round trip. */
	abort?: () => void;
	/**
	 * Shut the session down and let the owning instance dispatch its next
	 * item. Closed over the owner's refs, so any instance can call it: a
	 * cancel from a mount that holds no legacy uploader still unblocks the
	 * queue instead of parking the owner on a dead session.
	 */
	release: () => void;
};
 
type UploadStore = {
	queue: UploadItem[];
	subscribers: Set< () => void >;
	/** Dispatched queue id → its tus session. Absent means "never dispatched". */
	sessions: Map< string, UploadSession >;
	/**
	 * Ids whose row is gone but whose tus session may still emit. The owning
	 * instance checks this before attributing any callback, so an orphan's
	 * late progress/success can never land on the row dispatched after it.
	 */
	tombstones: Set< string >;
	/** The installed beforeunload guard, while any row is in flight. */
	unloadGuard?: ( event: BeforeUnloadEvent ) => void;
};
 
declare global {
	interface Window {
		[ STORE_KEY ]?: UploadStore;
	}
}
 
/**
 * Return the singleton upload store, creating it on first access. The store
 * lives on `window` so separately-built route bundles share one queue.
 *
 * @return The shared upload store.
 */
function getStore(): UploadStore {
	if ( ! window[ STORE_KEY ] ) {
		window[ STORE_KEY ] = {
			queue: [],
			subscribers: new Set(),
			sessions: new Map(),
			tombstones: new Set(),
		};
	}
	return window[ STORE_KEY ];
}
 
/**
 * Subscribe to upload-store changes. Returns an unsubscribe callback.
 *
 * @param notify - Called when the queue changes.
 * @return Unsubscribe.
 */
function subscribeStore( notify: () => void ): () => void {
	const store = getStore();
	store.subscribers.add( notify );
	return () => {
		store.subscribers.delete( notify );
	};
}
 
/**
 * Read the current queue snapshot. Must return a stable reference between
 * mutations so useSyncExternalStore can short-circuit unchanged renders.
 *
 * @return The current upload queue.
 */
function readQueue(): UploadItem[] {
	return getStore().queue;
}
 
/**
 * Install or remove the reload guard. The queue and its File objects are
 * window-scoped and unrecoverable after a reload, so an in-flight upload lost
 * to a stray refresh cannot be resumed — only warned about.
 *
 * @param store - The shared upload store.
 */
function syncUnloadGuard( store: UploadStore ): void {
	const hasInFlight = store.queue.some(
		item => item.status === 'pending' || item.status === 'uploading'
	);
	if ( hasInFlight && ! store.unloadGuard ) {
		const guard = ( event: BeforeUnloadEvent ) => {
			event.preventDefault();
			// Chrome still requires returnValue to be set.
			event.returnValue = '';
		};
		store.unloadGuard = guard;
		window.addEventListener( 'beforeunload', guard );
	} else if ( ! hasInFlight && store.unloadGuard ) {
		window.removeEventListener( 'beforeunload', store.unloadGuard );
		store.unloadGuard = undefined;
	}
}
 
/**
 * Apply a synchronous updater to the queue and notify subscribers.
 *
 * A no-op update is dropped rather than published: every mutation helper here
 * builds a fresh array, and publishing an equivalent one would re-render every
 * subscriber on writes that changed nothing (a delete that matched no row, a
 * draft write of unchanged values).
 *
 * @param updater - Pure function producing the next queue.
 */
function mutateQueue( updater: ( prev: UploadItem[] ) => UploadItem[] ): void {
	const store = getStore();
	const prev = store.queue;
	const next = updater( prev );
	if ( next.length === prev.length && next.every( ( item, index ) => item === prev[ index ] ) ) {
		return;
	}
	store.queue = next;
	syncUnloadGuard( store );
	store.subscribers.forEach( cb => cb() );
}
 
/**
 * Reset the shared upload store. Intended for tests; production code should
 * not call this.
 */
export function __resetUploadStoreForTests(): void {
	const store = window[ STORE_KEY ];
	if ( ! store ) {
		return;
	}
	store.queue = [];
	store.sessions.clear();
	store.tombstones.clear();
	syncUnloadGuard( store );
}
 
/**
 * Generate a unique id for a new upload queue item.
 *
 * @param file - The file being uploaded.
 * @return A unique string id prefixed with "upload-".
 */
function makeId( file: File ): string {
	return `upload-${ Date.now() }-${ Math.random().toString( 36 ).slice( 2, 7 ) }-${ file.name }`;
}
 
/**
 * Write (or clear) the edit session's dirty-field diff on a queue row.
 *
 * Store-level rather than part of the hook: the edit session writes through on
 * every keystroke, and the row must carry the draft across the handoff to
 * `/video/:id` and across the remounts the tab order causes mid-flow.
 *
 * @param id    - Queue item id.
 * @param draft - The dirty-field diff, or undefined to clear it.
 */
export function setUploadDraft(
	id: string,
	draft: Partial< VideoDetailsFormValues > | undefined
): void {
	mutateQueue( prev => prev.map( item => ( item.id === id ? { ...item, draft } : item ) ) );
}
 
/**
 * Drop every queue row bound to one of the given attachment ids.
 *
 * A deleted video leaves its success row behind otherwise: the pill keeps
 * offering "Add details" for a 404, and the upload flow re-adopts the dead row
 * into an eternal "processing" state.
 *
 * @param mediaIds - Attachment ids that no longer exist.
 */
export function removeUploadRowsForMedia( mediaIds: Array< number | string > ): void {
	const gone = new Set( mediaIds.map( String ) );
	mutateQueue( prev =>
		prev.filter( item => item.media === undefined || ! gone.has( String( item.media.id ) ) )
	);
}
 
/**
 * Whether a surface remounting mid-flow should re-adopt this row.
 *
 * Only rows that still have something to say: an upload in flight, or a
 * failure the user has not seen the end of. A settled success is the flow's
 * exit*, and adopting it resurrects a finished edit session — the "haunting"
 * this predicate exists to stop.
 *
 * @param item - The queue item.
 * @return True when the item is worth adopting.
 */
export function isAdoptableUpload( item: UploadItem ): boolean {
	return item.status === 'pending' || item.status === 'uploading' || item.status === 'failed';
}
 
/**
 * Subscribe to the shared upload queue via useSyncExternalStore so the
 * queue is a single source of truth across every useUpload() instance.
 *
 * @return The current upload queue.
 */
function useUploadQueue(): UploadItem[] {
	return useSyncExternalStore( subscribeStore, readQueue, readQueue );
}
 
/**
 * Wrap the legacy resumable (tus) uploader in a multi-item upload queue
 * backed by a window-attached singleton store.
 *
 * @return An object with the current upload queue and handlers to start or retry uploads.
 */
export function useUpload() {
	const client = useQueryClient();
	const queue = useUploadQueue();
 
	// Tracks which queue item is being handled by *this* instance's
	// legacy uploader. Only the instance that called startUpload (and
	// thus invoked uploadHandler) sets this; observer instances leave it
	// null because their legacy uploader is idle.
	const currentIdRef = useRef< string | null >( null );
 
	// uploadHandler is captured in a ref so callbacks can dispatch the
	// next pending upload without depending on the render-by-render
	// identity of `useResumableUploader`'s return value.
	const uploadHandlerRef = useRef< ( ( file: File ) => void ) | null >( null );
 
	// Queue ids dispatched but not yet handed a tus handle, oldest first. The
	// legacy uploader publishes `resumeHandler` through state one round trip
	// after the dispatch that produced it, and publishes exactly one per
	// dispatch — so FIFO is the binding, and reading `currentIdRef` at
	// binding time would mis-bind a handle that arrived after its own item
	// settled.
	const awaitingHandleRef = useRef< string[] >( [] );
 
	// `abandonSession` is defined below (it needs startNextPending, which needs
	// the dispatcher) but has to be reachable from a session registered at
	// dispatch time. The ref is the only knot that unties that cycle.
	const abandonSessionRef = useRef< ( id: string ) => void >( () => {} );
 
	/**
	 * Claim a queue item for this instance and hand it to the legacy uploader.
	 *
	 * The status flips to 'uploading' in the same mutation that claims the
	 * item, before the handler runs: another instance's startNextPending scans
	 * for 'pending' rows, and a row left pending after dispatch gets handed to
	 * the legacy uploader a second time.
	 */
	const dispatchItem = useCallback( ( id: string, file: File ) => {
		currentIdRef.current = id;
		getStore().sessions.set( id, { release: () => abandonSessionRef.current( id ) } );
		awaitingHandleRef.current.push( id );
		mutateQueue( prev =>
			prev.map( item => ( item.id === id ? { ...item, status: 'uploading' } : item ) )
		);
		uploadHandlerRef.current?.( file );
	}, [] );
 
	const startNextPending = useCallback( () => {
		const next = readQueue().find( item => item.status === 'pending' );
		if ( next && uploadHandlerRef.current ) {
			dispatchItem( next.id, next.file );
		} else {
			currentIdRef.current = null;
		}
	}, [ dispatchItem ] );
 
	/**
	 * Whether the callback now firing still belongs to a live row. False for a
	 * canceled session (row removed, id tombstoned) and for a row deleted out
	 * from under an active upload.
	 */
	const isLiveCurrent = useCallback( ( id: string ): boolean => {
		return ! getStore().tombstones.has( id ) && readQueue().some( item => item.id === id );
	}, [] );
 
	/**
	 * Forget a session that has run its course, and dispatch this instance's
	 * next pending item if `id` was the one it was holding.
	 */
	const finishSession = useCallback(
		( id: string ) => {
			const store = getStore();
			store.sessions.delete( id );
			store.tombstones.delete( id );
			Eif ( currentIdRef.current === id ) {
				currentIdRef.current = null;
				startNextPending();
			}
		},
		[ startNextPending ]
	);
 
	/** Stop a session that should not run to completion, then forget it. */
	const abandonSession = useCallback(
		( id: string ) => {
			getStore().sessions.get( id )?.abort?.();
			finishSession( id );
		},
		[ finishSession ]
	);
	abandonSessionRef.current = abandonSession;
 
	// Adapter callbacks — translate the legacy (bytesSent, bytesTotal) /
	// (data: VideoMediaProps) / (err) signatures to queue-item updates.
	//
	// Every callback resolves its row through isLiveCurrent first. The legacy
	// callbacks carry no identity, so an orphaned session (canceled while its
	// JWT round trip was still in flight, when there was nothing to abort)
	// would otherwise be attributed to whatever this instance dispatched next —
	// cross-attributed media and progress cross-talk.
	const { uploadHandler, resumeHandler } = useResumableUploader( {
		onProgress: ( bytesSent: number, bytesTotal: number ) => {
			const id = currentIdRef.current;
			if ( ! id ) {
				return;
			}
			if ( ! isLiveCurrent( id ) ) {
				abandonSession( id );
				return;
			}
			const progress = bytesTotal > 0 ? bytesSent / bytesTotal : 0;
			mutateQueue( prev =>
				prev.map( item => ( item.id === id ? { ...item, progress, status: 'uploading' } : item ) )
			);
		},
		onSuccess: ( data: VideoMediaProps ) => {
			const id = currentIdRef.current;
			// The attachment exists on the server whichever row this belongs
			// to — a cancel that lost the race with the last byte still leaves
			// a video — so refetch the library either way.
			client.invalidateQueries( { queryKey: [ LIBRARY_QUERY_KEY ] } );
			if ( ! id ) {
				return;
			}
			if ( ! isLiveCurrent( id ) ) {
				abandonSession( id );
				return;
			}
			// Succeeded items stay in the queue, carrying their media result,
			// until a consumer acknowledges them — the upload pill links to
			// the finished video, and the upload→edit transition needs the id.
			// (They used to self-delete after 2s, which threw the id away.)
			mutateQueue( prev =>
				prev.map( item =>
					item.id === id ? { ...item, progress: 1, status: 'success', media: data } : item
				)
			);
			// First run ends the moment a video reaches the backend, whatever
			// started it. Owning this here rather than in the edit step is what
			// makes it true for every row of a multi-drop batch and for uploads
			// begun from the Library, which never touch that step.
			markFirstPublish();
			finishSession( id );
		},
		onError: ( err: unknown ) => {
			const id = currentIdRef.current;
			if ( ! id ) {
				return;
			}
			if ( ! isLiveCurrent( id ) ) {
				abandonSession( id );
				return;
			}
			let message: string = __( 'Upload failed.', 'jetpack-videopress-pkg' );
			if ( err instanceof Error ) {
				message = err.message;
			} else Eif ( typeof err === 'string' ) {
				message = err;
			}
			// Errors reach here from two places — the token step and tus — and only
			// some carry a code. Read it off whatever arrived rather than narrowing
			// to a type, since an uncoded error is a normal outcome here, not a bug.
			const errorCode =
				typeof ( err as { code?: unknown } )?.code === 'string'
					? ( err as { code: string } ).code
					: undefined;
			mutateQueue( prev =>
				prev.map( item =>
					item.id === id ? { ...item, status: 'failed', error: message, errorCode } : item
				)
			);
			// Failed items stay in the queue so the user can retry. Move
			// on to the next pending item rather than blocking the queue.
			finishSession( id );
		},
	} );
 
	uploadHandlerRef.current = uploadHandler;
 
	// Bind the tus handle to the item it was dispatched for. Until this runs
	// the session has no abort — cancelling in that window can only tombstone
	// and wait, which is why a tombstoned id is aborted here the moment its
	// handle arrives instead of dispatching the next item into its callbacks.
	useEffect( () => {
		if ( ! resumeHandler ) {
			return;
		}
		const store = getStore();
		// Skip ids whose session already ended without ever producing a handle
		// (a failed upload-JWT round trip errors before tus exists), so the
		// FIFO never drifts out of step with the handles it is binding.
		let id = awaitingHandleRef.current.shift();
		while ( id && ! store.sessions.has( id ) ) {
			id = awaitingHandleRef.current.shift();
		}
		if ( ! id ) {
			return;
		}
		const session = store.sessions.get( id );
		Eif ( session ) {
			session.abort = resumeHandler.abort;
		}
		if ( store.tombstones.has( id ) ) {
			abandonSession( id );
		}
	}, [ resumeHandler, abandonSession ] );
 
	const startUpload = useCallback(
		( file: File, context?: string ): string => {
			const id = makeId( file );
			mutateQueue( prev => [
				...prev,
				{
					id,
					file,
					progress: 0,
					status: 'pending',
					context,
					enqueuedAt: new Date().toISOString(),
				},
			] );
			// Only dispatch immediately when this instance's legacy
			// uploader is idle. Otherwise the item waits in the queue
			// and is picked up by startNextPending when the active
			// upload settles.
			if ( ! currentIdRef.current ) {
				dispatchItem( id, file );
			}
			return id;
		},
		[ dispatchItem ]
	);
 
	/**
	 * Remove a settled (succeeded or failed) item from the queue once its
	 * outcome has been surfaced to the user. In-flight items are left alone —
	 * cancellation is a different operation from acknowledgement.
	 */
	const acknowledgeUpload = useCallback( ( id: string ) => {
		mutateQueue( prev =>
			prev.filter(
				item => item.id !== id || ( item.status !== 'success' && item.status !== 'failed' )
			)
		);
	}, [] );
 
	// Cancel an upload: the row goes, from any instance. There is no 'canceled'
	// status — a user-initiated cancel is not a failure, and leaving debris
	// that offers Retry misreads the intent entirely.
	//
	// Aborting the tus session is the part that cannot always happen here. The
	// session map is store-level, so a non-owning instance aborts just as
	// directly as the owner. But the handle only materializes after the
	// upload-JWT round trip, so a cancel landing inside that window has nothing
	// to abort: it tombstones the id and leaves the owner parked on it, which
	// is what stops the next item being dispatched into the orphan's
	// callbacks. The owner aborts and advances as soon as the handle lands (or
	// on the orphan's first callback, whichever comes first).
	//
	// Settled rows are a no-op — acknowledge/retry own those states.
	const cancelUpload = useCallback( ( id: string ) => {
		const item = readQueue().find( q => q.id === id );
		if ( ! item || item.status === 'success' || item.status === 'failed' ) {
			return;
		}
		const store = getStore();
		const session = store.sessions.get( id );
		if ( ! session ) {
			// Never dispatched: no session to abort, no owner to notify.
			mutateQueue( prev => prev.filter( q => q.id !== id ) );
			return;
		}
		store.tombstones.add( id );
		mutateQueue( prev => prev.filter( q => q.id !== id ) );
		if ( session.abort ) {
			session.release();
		}
	}, [] );
 
	const retryUpload = useCallback(
		( id: string ) => {
			const item = readQueue().find( q => q.id === id );
			if ( ! item ) {
				return;
			}
			mutateQueue( prev =>
				prev.map( q =>
					q.id === id
						? { ...q, status: 'pending', progress: 0, error: undefined, errorCode: undefined }
						: q
				)
			);
			// Dispatch immediately if idle; otherwise wait for the
			// active upload to settle and startNextPending to pick this
			// up.
			Eif ( ! currentIdRef.current ) {
				dispatchItem( id, item.file );
			}
		},
		[ dispatchItem ]
	);
 
	return {
		uploadQueue: queue,
		startUpload,
		retryUpload,
		cancelUpload,
		acknowledgeUpload,
	};
}