1import { Button } from "./Button";
2import * as Dialog from "./Dialog";
3import { createContext, useContext, useEffect, useEffectEvent } from "react";
4import { Modal, View } from "react-native";
5import ImageViewing from "react-native-image-viewing";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { openAttachmentInViewer } from "../../lib/attachmentDownload";
9import { useMediaActions } from "../../state/mediaActions";
10import type {
11 FilePreviewImages,
12 FilePreviewModalProps,
13 FilePreviewViewerProps,
14 ResolvedFilePreviewDocument,
15} from "./FilePreviewModal.types";
16import { FilePreviewSession } from "./FilePreviewSession";
17import { MediaPreviewHeader } from "../MediaPreviewHeader";
18
19export type {
20 FilePreviewItem,
21 FilePreviewSource,
22 ResolvedFilePreviewSource,
23} from "./FilePreviewModal.types";
24import { PdfPreview } from "../PdfPreview";
25import { SheetHeader } from "./SheetHeader";
26import { AppText as Text } from "./AppText";
27
28function PdfDocumentPreview(props: {
29 readonly source: ResolvedFilePreviewDocument;
30 readonly onRequestClose: () => void;
31}) {
32 const insets = useSafeAreaInsets();
33 const media = useMediaActions(props.source.actionsSource);
34 const save = media.actions.find((action) => action.id === "save");
35 return (
36 <Modal
37 animationType="slide"
38 onRequestClose={props.onRequestClose}
39 statusBarTranslucent
40 navigationBarTranslucent
41 >
42 <View
43 className="flex-1 bg-card"
44 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
45 >
46 <SheetHeader
47 presentation="inline"
48 title={props.source.name ?? "PDF"}
49 onClose={props.onRequestClose}
50 actions={
51 save
52 ? [
53 {
54 accessibilityLabel: "Save or share",
55 icon: "square.and.arrow.up",
56 disabled: save.disabled,
57 loading: media.sharing,
58 onPress: () => void save.run(),
59 },
60 ]
61 : []
62 }
63 />
64 <PdfPreview uri={props.source.uri} />
65 </View>
66 </Modal>
67 );
68}
69
70function DocumentPreview(props: {
71 readonly source: ResolvedFilePreviewDocument;
72 readonly onRequestClose: () => void;
73 readonly onOpenError?: (error: unknown) => void;
74}) {
75 const { uri, name } = props.source;
76 const onRequestClose = useEffectEvent(props.onRequestClose);
77 const onOpenError = useEffectEvent((error: unknown) => {
78 if (props.onOpenError) props.onOpenError(error);
79 else
80 Dialog.alert(
81 "Could not open document",
82 "A compatible viewer must be installed. Check your connection and try again.",
83 );
84 });
85 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the attachment in an external viewer and aborts the open on cleanup
86 useEffect(() => {
87 const controller = new AbortController();
88 const input = {
89 uri,
90 attachment: {
91 name: name ?? "Document",
92 mimeType:
93 props.source.mimeType ??
94 (props.source.kind === "pdf" ? "application/pdf" : "application/octet-stream"),
95 },
96 signal: controller.signal,
97 };
98 const opened = openAttachmentInViewer(input);
99 void opened
100 .catch((error: unknown) => {
101 if (!controller.signal.aborted) onOpenError(error);
102 })
103 .finally(() => {
104 if (!controller.signal.aborted) onRequestClose();
105 });
106 return () => controller.abort();
107 }, [uri, name, props.source.mimeType, props.source.kind]);
108 return (
109 <Modal transparent animationType="fade" onRequestClose={props.onRequestClose}>
110 <View className="flex-1 items-center justify-center bg-backdrop p-6">
111 <View className="w-full max-w-sm gap-4 rounded-2xl bg-sheet-solid p-6">
112 <Text className="font-t3-semibold text-foreground">Opening document…</Text>
113 <Text className="text-foreground-muted" numberOfLines={2}>
114 {name ?? "Document"}
115 </Text>
116 <View className="items-end">
117 <Button label="Cancel" tone="text" onPress={props.onRequestClose} />
118 </View>
119 </View>
120 </View>
121 </Modal>
122 );
123}
124
125type ImagePreviewProps = {
126 readonly source: FilePreviewImages;
127 readonly onRequestClose: () => void;
128};
129
130const ImagePreviewContext = createContext<ImagePreviewProps | null>(null);
131
132function ImagePreviewHeader(header: { readonly imageIndex: number }) {
133 const props = useContext(ImagePreviewContext)!;
134 const source = props.source.images[header.imageIndex];
135 const insets = useSafeAreaInsets();
136 const mediaActions = useMediaActions(source?.actionsSource, props.onRequestClose);
137 return (
138 <View className="bg-black/70" style={{ paddingTop: insets.top }}>
139 <MediaPreviewHeader
140 title={source?.name ?? "Image"}
141 media={mediaActions}
142 closeAccessibilityLabel="Close image"
143 onClose={props.onRequestClose}
144 />
145 </View>
146 );
147}
148
149// Images still resolving show the viewer's loading page, and remount by key once their URL arrives.
150function imageViewingProps(source: FilePreviewImages) {
151 return {
152 images: source.images.map((image) => (image.uri ? { uri: image.uri } : {})),
153 keyExtractor: (_: unknown, index: number) => source.images[index]?.uri ?? `pending:${index}`,
154 // Stays fixed: react-native-image-viewing remounts the Modal and replays its fade when it changes.
155 imageIndex: source.selectedIndex,
156 };
157}
158
159function ImagePreview(props: ImagePreviewProps) {
160 return (
161 <ImagePreviewContext value={props}>
162 <ImageViewing
163 {...imageViewingProps(props.source)}
164 visible
165 presentationStyle="fullScreen"
166 onRequestClose={props.onRequestClose}
167 swipeToCloseEnabled={false}
168 doubleTapToZoomEnabled
169 HeaderComponent={ImagePreviewHeader}
170 />
171 </ImagePreviewContext>
172 );
173}
174
175function AndroidFilePreview(props: FilePreviewViewerProps) {
176 const { source } = props;
177 if (source.kind === "pdf") return <PdfDocumentPreview {...props} source={source} />;
178 if (source.kind !== "image") return <DocumentPreview {...props} source={source} />;
179 if (source.images[source.selectedIndex]?.actionsSource)
180 return <ImagePreview source={source} onRequestClose={props.onRequestClose} />;
181 return (
182 <ImageViewing
183 {...imageViewingProps(source)}
184 visible
185 onRequestClose={props.onRequestClose}
186 swipeToCloseEnabled={false}
187 doubleTapToZoomEnabled
188 />
189 );
190}
191
192/** Shows images, PDFs and documents full screen. */
193export function FilePreviewModal(props: FilePreviewModalProps) {
194 return <FilePreviewSession {...props} progressiveGallery={false} Viewer={AndroidFilePreview} />;
195}