Skip to content
Source
Sign inSign up

FilePreviewModal.tsx

104 lines3.8 KB

1import * as Dialog from "./Dialog";
2import { requireNativeModule } from "expo";
3import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
4​
5import type {
6 FilePreviewImage,
7 FilePreviewModalProps,
8 FilePreviewViewerProps,
9} from "./FilePreviewModal.types";
10import { FilePreviewSession } from "./FilePreviewSession";
11​
12export type {
13 FilePreviewItem,
14 FilePreviewSource,
15 ResolvedFilePreviewSource,
16} from "./FilePreviewModal.types";
17​
18const NativeControls = requireNativeModule<{
19 presentFile(
20 uri: string,
21 name: string,
22 sourceIdentifier: string,
23 identifier: string,
24 ): Promise<void>;
25 presentFiles(
26 uris: readonly (string | null)[],
27 names: readonly string[],
28 selectedIndex: number,
29 sourceIdentifiers: readonly string[],
30 identifier: string,
31 ): Promise<void>;
32 updateFile(identifier: string, index: number, uri: string | null): Promise<void>;
33 dismissFile(identifier: string): Promise<void>;
34}>("T3NativeControls");
35​
36// Null tells the viewer an image failed; undefined means its URL has not arrived yet.
37const nativeUri = (image: FilePreviewImage) => (image.failed ? null : image.uri);
38​
39function NativeFilePreview(props: FilePreviewViewerProps) {
40 const identifier = useId();
41 // Later URLs reach the open viewer through updateFile instead of presenting again.
42 const [presented] = useState(props.source);
43 const delivered = useRef<(string | null | undefined)[]>([]);
44 const onRequestClose = useEffectEvent(props.onRequestClose);
45 const onOpenError = useEffectEvent((error: unknown) => {
46 if (props.onOpenError) props.onOpenError(error);
47 else Dialog.alert("Could not open preview", "The file could not be loaded. Please try again.");
48 });
49​
50 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native file preview and dismisses it on cleanup
51 useEffect(() => {
52 let canceled = false;
53 let presentation: Promise<void>;
54 // Images open while still loading; other files must download first so Quick Look can refuse them.
55 if (presented.kind === "image") {
56 const { images, selectedIndex } = presented;
57 delivered.current = images.map((image) => image.uri);
58 presentation = NativeControls.presentFiles(
59 images.map((image) => image.uri ?? null),
60 images.map((image) => image.name ?? "Preview"),
61 selectedIndex,
62 images.map((image) => image.sourceIdentifier ?? ""),
63 identifier,
64 );
65 } else {
66 const { uri, name, sourceIdentifier } = presented;
67 presentation = NativeControls.presentFile(
68 uri,
69 name ?? "Preview",
70 sourceIdentifier ?? "",
71 identifier,
72 );
73 }
74 void presentation
75 .catch((error: unknown) => {
76 if (!canceled) onOpenError(error);
77 })
78 .finally(() => {
79 if (!canceled) onRequestClose();
80 });
81 return () => {
82 canceled = true;
83 void NativeControls.dismissFile(identifier).catch(() => undefined);
84 };
85 }, [presented, identifier]);
86​
87 const images = props.source.kind === "image" ? props.source.images : undefined;
88 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hands image URLs that resolve after opening to the native viewer
89 useEffect(() => {
90 images?.forEach((image, index) => {
91 const uri = nativeUri(image);
92 if (uri === undefined || delivered.current[index] === uri) return;
93 delivered.current[index] = uri;
94 void NativeControls.updateFile(identifier, index, uri).catch(() => undefined);
95 });
96 }, [images, identifier]);
97​
98 return null;
100​
101/** Shows images, PDFs and documents full screen in the native viewer (Android: FilePreviewModal.android.tsx). */
102export function FilePreviewModal(props: FilePreviewModalProps) {
103 return <FilePreviewSession {...props} progressiveGallery Viewer={NativeFilePreview} />;