1import { useNavigation } from "@react-navigation/native";
2import {
3 useCallback,
4 useContext,
5 useEffect,
6 useEffectEvent,
7 useState,
8 useSyncExternalStore,
9 useMemo,
10} from "react";
11import { Alert, Keyboard } from "react-native";
12
13import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
14import { useRefreshAssetUrl } from "../state/assets";
15import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery";
16import { FilePreviewGallery } from "./FilePreviewGallery";
17import { FilePreview } from "./FilePreview";
18import type {
19 FilePreviewItem,
20 FilePreviewSource,
21 ResolvedFilePreviewSource,
22} from "./FilePreviewModal.types";
23
24export type {
25 FilePreviewItem,
26 FilePreviewSource,
27 ResolvedFilePreviewSource,
28} from "./FilePreviewModal.types";
29
30function PreviewItemResolver(props: {
31 readonly source: FilePreviewItem;
32 readonly onResolved: (source: ResolvedFilePreviewSource) => void;
33 readonly onRequestClose: () => void;
34 readonly onOpenError?: (error: unknown) => void;
35}) {
36 const { source } = props;
37 const environmentId = "environmentId" in source ? source.environmentId : null;
38 const refreshAssetUrl = useRefreshAssetUrl(
39 environmentId,
40 "resource" in source ? source.resource : null,
41 );
42 // Resolve once per presentation; background URL refreshes must not reopen the native viewer.
43 const [uri, setUri] = useState<string | null>("uri" in source ? source.uri : null);
44 const onRequestClose = useEffectEvent(props.onRequestClose);
45 const onResolutionError = useEffectEvent((error: unknown, fallbackMessage: string) => {
46 if (props.onOpenError) props.onOpenError(error);
47 else Alert.alert("Could not open preview", fallbackMessage);
48 onRequestClose();
49 });
50 useEffect(() => Keyboard.dismiss(), []);
51 useEffect(() => {
52 if (environmentId === null || uri !== null) return;
53 let cancelled = false;
54 // A cached URL may have expired while the app was suspended. Await reauthorization
55 // before handing a URL to Quick Look or ACTION_VIEW, which retain that URL.
56 void refreshAssetUrl()
57 .then((url) => {
58 if (cancelled) return;
59 if (!url) throw new Error("Reconnect to this environment and try again.");
60 setUri(url + (source.srcFragment ?? ""));
61 })
62 .catch((error: unknown) => {
63 if (cancelled) return;
64 onResolutionError(
65 error,
66 "Reconnect to this environment and try again. The file may have been moved or deleted.",
67 );
68 });
69 return () => {
70 cancelled = true;
71 };
72 }, [environmentId, uri, refreshAssetUrl, source.srcFragment]);
73 useEffect(() => {
74 if (!("attachment" in source)) return;
75 const controller = new AbortController();
76 let release: (() => void) | undefined;
77 void loadLocalAttachmentPreview(source.attachment, controller.signal)
78 .then((file) => {
79 if (!file) return;
80 if (controller.signal.aborted) {
81 file.dispose();
82 return;
83 }
84 release = file.dispose;
85 setUri(file.uri);
86 })
87 .catch((error: unknown) => {
88 if (controller.signal.aborted) return;
89 onResolutionError(error, "Attach the file again and retry.");
90 });
91 return () => {
92 controller.abort();
93 release?.();
94 };
95 }, [source]);
96
97 const onResolved = useEffectEvent(props.onResolved);
98 useEffect(() => {
99 if (uri !== null) onResolved({ ...source, name: filePreviewName(source), uri });
100 }, [uri, source]);
101 return null;
102}
103
104function ResolvedFilePreview(props: {
105 readonly source: FilePreviewSource;
106 readonly onRequestClose: () => void;
107 readonly onOpenError?: (error: unknown) => void;
108}) {
109 // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer.
110 const [items] = useState(() => filePreviewGallery(props.source, []));
111 const [resolved, setResolved] = useState<Record<string, ResolvedFilePreviewSource>>({});
112 const onResolved = useCallback((item: ResolvedFilePreviewSource, key: string) => {
113 setResolved((current) => ({ ...current, [key]: item }));
114 }, []);
115 const selected = resolved[filePreviewKey(props.source)];
116 const gallery = useMemo(
117 () =>
118 items.flatMap((item) => {
119 const value = resolved[filePreviewKey(item)];
120 return value ? [value] : [];
121 }),
122 [items, resolved],
123 );
124 const ready = selected && gallery.length === items.length;
125 return (
126 <>
127 {items.map((item) => (
128 <PreviewItemResolver
129 key={filePreviewKey(item)}
130 source={item}
131 onResolved={(value) => onResolved(value, filePreviewKey(item))}
132 onRequestClose={props.onRequestClose}
133 onOpenError={props.onOpenError}
134 />
135 ))}
136 {ready ? (
137 <FilePreview
138 source={{ ...selected, gallery }}
139 onRequestClose={props.onRequestClose}
140 onOpenError={props.onOpenError}
141 />
142 ) : null}
143 </>
144 );
145}
146
147export function FilePreviewModal(props: {
148 readonly source: FilePreviewSource | null;
149 readonly onRequestClose: () => void;
150 /** Replaces the default alert when the platform cannot open the document. */
151 readonly onOpenError?: (error: unknown) => void;
152}) {
153 const group = useContext(FilePreviewGallery);
154 const source = useMemo(
155 () =>
156 props.source === null
157 ? null
158 : {
159 ...props.source,
160 gallery: filePreviewGallery(props.source, group),
161 },
162 [props.source, group],
163 );
164 const navigation = useNavigation();
165 const subscribeToFocus = useCallback(
166 (listener: () => void) => {
167 const offFocus = navigation.addListener("focus", listener);
168 const offBlur = navigation.addListener("blur", listener);
169 return () => {
170 offFocus();
171 offBlur();
172 };
173 },
174 [navigation],
175 );
176 // The v5 card host sits inside a legacy presentation envelope whose focus context
177 // can describe a different route. The preview belongs to this navigation route.
178 const isFocused = useSyncExternalStore(
179 subscribeToFocus,
180 navigation.isFocused,
181 navigation.isFocused,
182 );
183 const hasSource = props.source !== null;
184 const onRequestClose = useEffectEvent(props.onRequestClose);
185 useEffect(() => {
186 if (!isFocused && hasSource) onRequestClose();
187 }, [isFocused, hasSource]);
188
189 if (!source || !isFocused) return null;
190 return (
191 <ResolvedFilePreview
192 key={filePreviewKey(source)}
193 source={source}
194 onRequestClose={props.onRequestClose}
195 {...(props.onOpenError ? { onOpenError: props.onOpenError } : {})}
196 />
197 );
198}