Line data Source code
1 : import * as React from 'react';
2 :
3 : import { PlatformFile } from '../platform/file/file.js';
4 : import type { SessionBundle } from '../storage/session_bundle.js';
5 : import { readSessionBundleFromZip } from '../session/persistence/session_import.js';
6 : import { useSessionImport } from '../session/persistence/session_import_provider.js';
7 : import { SessionImportCard, type SessionImportCardDependencies } from '../session/persistence/session_import_card.js';
8 : import { OPEN_LINK_SESSION, useRouterNavigate } from './router/router.js';
9 :
10 : type PreparationPhase = 'reading' | 'validating';
11 :
12 : type FileLoadState =
13 : | { phase: 'reading' }
14 : | { phase: 'validating'; fileByteCount: number }
15 : | { phase: 'ready'; bundle: SessionBundle; fileByteCount: number }
16 : | { phase: 'importing'; bundle: SessionBundle; fileByteCount: number }
17 : | { phase: 'failed'; failedPhase: PreparationPhase | 'importing'; error: Error; fileByteCount: number | null };
18 :
19 : export interface FileLoaderDependencies {
20 : sessionImportCard?: SessionImportCardDependencies;
21 : readSessionBundleFromZip: typeof readSessionBundleFromZip;
22 : useSessionImport(): Pick<ReturnType<typeof useSessionImport>, 'importPortableBundle'>;
23 : useRouterNavigate: typeof useRouterNavigate;
24 : }
25 :
26 1 : const DEFAULT_DEPENDENCIES: FileLoaderDependencies = {
27 1 : readSessionBundleFromZip,
28 1 : useSessionImport,
29 1 : useRouterNavigate,
30 1 : };
31 :
32 : interface Props {
33 : file: PlatformFile;
34 : onDone(): void;
35 : dependencies?: FileLoaderDependencies;
36 : }
37 :
38 1 : export function FileLoader({ file, onDone, dependencies = DEFAULT_DEPENDENCIES }: Props): React.ReactElement {
39 1 : const navigate = dependencies.useRouterNavigate();
40 1 : const { importPortableBundle } = dependencies.useSessionImport();
41 1 : const [attempt, setAttempt] = React.useState(0);
42 1 : const [state, setState] = React.useState<FileLoadState>({ phase: 'reading' });
43 1 : const prepareFile = React.useEffectEvent(async (input: PlatformFile, signal: AbortSignal) => {
44 1 : try {
45 1 : setState({ phase: 'reading' });
46 1 : const bytes = await input.readAsArrayBuffer();
47 1 : signal.throwIfAborted();
48 :
49 1 : setState({ phase: 'validating', fileByteCount: bytes.byteLength });
50 1 : const zipBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
51 1 : const zipBlob = new Blob([zipBuffer], { type: 'application/zip' });
52 1 : const bundle = await dependencies.readSessionBundleFromZip(zipBlob);
53 1 : signal.throwIfAborted();
54 :
55 1 : setState({ phase: 'ready', bundle, fileByteCount: bytes.byteLength });
56 1 : } catch (error) {
57 1 : if (signal.aborted) return;
58 1 : setState(current => ({
59 1 : phase: 'failed',
60 1 : failedPhase: current.phase === 'validating' ? 'validating' : 'reading',
61 1 : error: toError(error),
62 1 : fileByteCount: 'fileByteCount' in current ? current.fileByteCount : null,
63 1 : }));
64 1 : }
65 1 : });
66 :
67 1 : React.useEffect(() => {
68 1 : const abortController = new AbortController();
69 1 : void prepareFile(file, abortController.signal);
70 1 : return () => abortController.abort();
71 1 : }, [file, attempt]);
72 :
73 1 : const requestClose = React.useCallback(() => {
74 0 : if (state.phase !== 'importing') onDone();
75 1 : }, [onDone, state.phase]);
76 1 : const retry = React.useCallback(() => setAttempt(value => value + 1), []);
77 1 : const startImport = React.useEffectEvent(async (bundle: SessionBundle, fileByteCount: number) => {
78 1 : setState({ phase: 'importing', bundle, fileByteCount });
79 1 : try {
80 1 : const sessionId = await importPortableBundle(bundle, {
81 1 : presentation: { mode: 'centered' },
82 1 : });
83 1 : if (sessionId == null) {
84 1 : setState({ phase: 'ready', bundle, fileByteCount });
85 1 : return;
86 1 : }
87 1 : navigate({ type: OPEN_LINK_SESSION, value: sessionId });
88 1 : onDone();
89 1 : } catch (error) {
90 1 : setState({
91 1 : phase: 'failed',
92 1 : failedPhase: 'importing',
93 1 : error: toError(error),
94 1 : fileByteCount,
95 1 : });
96 1 : }
97 1 : });
98 1 : const requestImport = React.useCallback(() => {
99 1 : if (state.phase === 'ready') void startImport(state.bundle, state.fileByteCount);
100 1 : }, [state]);
101 :
102 1 : switch (state.phase) {
103 1 : case 'reading':
104 1 : return <SessionImportCard phase="file-loading" sourcePath={file.path} stage="reading" fileByteCount={null} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
105 1 : case 'validating':
106 1 : return <SessionImportCard phase="file-loading" sourcePath={file.path} stage="validating" fileByteCount={state.fileByteCount} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
107 1 : case 'ready':
108 1 : return <SessionImportCard phase="file-ready" sourcePath={file.path} fileByteCount={state.fileByteCount}
109 1 : bundle={state.bundle} busy={false} onImport={requestImport} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
110 1 : case 'importing':
111 1 : return <SessionImportCard phase="file-ready" sourcePath={file.path} fileByteCount={state.fileByteCount}
112 1 : bundle={state.bundle} busy onImport={requestImport} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
113 1 : case 'failed':
114 1 : return <SessionImportCard phase="file-error" sourcePath={file.path} fileByteCount={state.fileByteCount}
115 1 : failedStage={state.failedPhase} errorMessage={state.error.message} onRetry={retry} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
116 1 : }
117 1 : }
118 :
119 1 : function toError(error: unknown): Error {
120 1 : return error instanceof Error ? error : new Error(String(error));
121 1 : }
|