1import * as Dialog from "../../components/ui/Dialog";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import {
5 clampFileAttachmentUploadBytes,
6 formatAttachmentSize,
7} from "@t3tools/client-runtime/state/attachments";
8import { useRef, useState } from "react";
9import { View } from "react-native";
10
11import { AppBar, type AppBarMenu } from "../../components/ui/AppBar";
12import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
13import { useAttachmentDocument } from "../../lib/attachmentDocument";
14import { nativeViewerErrorMessage } from "../../lib/attachmentDownload";
15import { isFileBackedComposerAttachment } from "../../lib/composerImages";
16import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
17import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts";
18import { useEnvironmentServerConfig } from "../../state/entities";
19import { useRefreshAssetUrl } from "../../state/assets";
20import { scopedThreadKey } from "../../lib/scopedEntities";
21import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
22import { isZipFile, ZipArchiveView } from "./ZipArchiveView";
23import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody";
24
25export type AttachmentFileRouteParams = {
26 readonly environmentId?: string;
27 readonly threadId?: string;
28 readonly attachmentId: string;
29 readonly name: string;
30 readonly mimeType: string;
31 readonly sizeBytes: string;
32 readonly draftKey?: string;
33 readonly htmlRender?: "1";
34};
35
36type AttachmentFileScreenProps = StaticScreenProps<AttachmentFileRouteParams>;
37
38export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
39 const navigation = useNavigation();
40 const { appearance, setCodeWordBreak } = useAppearancePreferences();
41 const params = props.route.params;
42 const environmentId = params.environmentId?.trim()
43 ? EnvironmentId.make(params.environmentId)
44 : null;
45 const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0;
46 const draftKey = params.draftKey ?? null;
47 const draft = useComposerDraft(draftKey);
48 const draftAttachment = draftKey
49 ? draft.attachments.find((entry) => entry.id === params.attachmentId)
50 : undefined;
51 const localAttachment =
52 draftAttachment && isFileBackedComposerAttachment(draftAttachment) ? draftAttachment : null;
53 const document = useAttachmentDocument({
54 name: params.name,
55 mimeType: params.mimeType,
56 sizeBytes,
57 attachmentId: params.attachmentId,
58 environmentId,
59 attachment: localAttachment,
60 });
61 const [nativeOpen, setNativeOpen] = useState(false);
62 const nativeKind = nativeViewerKind(document.kind);
63 const [nativeViewer, setNativeViewer] = useState<"pending" | "open" | "unavailable" | null>(
64 nativeKind ? "pending" : null,
65 );
66 const [nativeError, setNativeError] = useState<string | null>(null);
67 const pendingNativeError = useRef<string | null>(null);
68 const handleBack = () => {
69 if (navigation.canGoBack()) navigation.goBack();
70 };
71 const { uri, resource } = document;
72 // A file only the native viewer can show opens it as soon as the file resolves.
73 if (nativeViewer === "pending" && uri) {
74 setNativeViewer("open");
75 setNativeOpen(true);
76 }
77 // Mint a fresh URL: cached URLs may expire in the background before Quick Look or ACTION_VIEW opens.
78 const nativeSource = ((): FilePreviewSource | null => {
79 const kind = nativeKind ?? "document";
80 const base = { kind, name: params.name, mimeType: params.mimeType };
81 if (localAttachment) return { ...base, attachment: localAttachment };
82 if (environmentId) return { ...base, environmentId, resource };
83 return uri ? { ...base, uri } : null;
84 })();
85 const handleNativeOpenError = (error: unknown) => {
86 pendingNativeError.current = nativeViewerErrorMessage(error);
87 };
88 const handleNativeClose = () => {
89 setNativeOpen(false);
90 const message = pendingNativeError.current;
91 pendingNativeError.current = null;
92 if (nativeViewer === null) {
93 if (message) Dialog.alert("Could not open document", message);
94 return;
95 }
96 if (message) {
97 setNativeError(message);
98 setNativeViewer("unavailable");
99 return;
100 }
101 handleBack();
102 };
103 const removeFromDraft = () => {
104 if (!draftKey) return;
105 removeComposerDraftAttachment(draftKey, params.attachmentId);
106 handleBack();
107 };
108
109 const { content, renderedMode, activeMode, setRendered, share, sharing } = document;
110 const menuActions = [
111 renderedMode
112 ? ({
113 id: "preview",
114 title: renderedMode === "table" ? "Table" : "Preview",
115 icon: renderedMode === "table" ? "tablecells" : "eye",
116 inline: true,
117 onPress: () => setRendered(true),
118 } as const)
119 : null,
120 renderedMode
121 ? ({
122 id: "source",
123 title: "Source",
124 icon: "doc.text",
125 inline: true,
126 onPress: () => setRendered(false),
127 } as const)
128 : null,
129 content && activeMode === "source"
130 ? ({
131 id: "word-wrap",
132 title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap",
133 icon: "text.alignleft",
134 inline: false,
135 onPress: () => setCodeWordBreak(!appearance.codeWordBreak),
136 } as const)
137 : null,
138 content
139 ? ({
140 id: "copy",
141 title: content.truncated ? "Copy preview" : "Copy contents",
142 icon: "doc.on.doc",
143 inline: false,
144 onPress: () => copyTextWithHaptic(content.text),
145 } as const)
146 : null,
147 uri
148 ? ({
149 id: "share",
150 title: sharing ? "Opening share sheet…" : "Save or share",
151 icon: "square.and.arrow.up",
152 inline: false,
153 onPress: () => void share(),
154 } as const)
155 : null,
156 uri
157 ? ({
158 id: "open-viewer",
159 title: "Open in file viewer",
160 icon: "arrow.up.left.and.arrow.down.right",
161 inline: false,
162 onPress: () => {
163 setNativeViewer((current) => (current === null ? null : "open"));
164 setNativeOpen(true);
165 },
166 } as const)
167 : null,
168 draftKey
169 ? ({
170 id: "remove",
171 title: "Remove from draft",
172 icon: "trash",
173 inline: false,
174 destructive: true,
175 onPress: removeFromDraft,
176 } as const)
177 : null,
178 ].filter((action) => action !== null);
179 const selectedAction = activeMode === "source" ? "source" : "preview";
180 const fileMenu: AppBarMenu = {
181 title: "File actions",
182 icon: "ellipsis",
183 items: [
184 ...(renderedMode
185 ? [
186 {
187 id: "modes",
188 inline: true,
189 items: menuActions
190 .filter(({ inline }) => inline)
191 .map((action) => ({
192 id: action.id,
193 title: action.title,
194 icon: action.icon,
195 selected: action.id === selectedAction,
196 onPress: action.onPress,
197 })),
198 },
199 ]
200 : []),
201 ...menuActions
202 .filter(({ inline }) => !inline)
203 .map((action) => ({
204 id: action.id,
205 title: action.title,
206 icon: action.icon,
207 destructive: "destructive" in action,
208 onPress: action.onPress,
209 })),
210 ],
211 };
212 const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}`;
213 const refresh = useRefreshAssetUrl(environmentId, resource);
214 const capabilities = useEnvironmentServerConfig(environmentId)?.environment.capabilities;
215
216 if (document.kind === "archive" && isZipFile(params) && !document.error) {
217 const threadDraftKey =
218 environmentId && params.threadId
219 ? scopedThreadKey(environmentId, ThreadId.make(params.threadId))
220 : null;
221 return (
222 <ZipArchiveView
223 name={params.name}
224 subtitle={subtitle}
225 sizeBytes={sizeBytes}
226 uri={uri}
227 isLocal={localAttachment !== null}
228 refresh={refresh}
229 environmentId={environmentId}
230 // Entries go to the thread's chat composer, also from a question-answer draft.
231 draftKey={threadDraftKey ?? draftKey}
232 maxAttachmentBytes={
233 capabilities?.attachmentUploads && capabilities.fileAttachments
234 ? clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes)
235 : undefined
236 }
237 onSave={() => void share()}
238 onRemove={draftKey ? removeFromDraft : null}
239 />
240 );
241 }
242
243 return (
244 <View className="flex-1 bg-sheet">
245 <AppBar title={params.name} subtitle={subtitle} onBack={handleBack} menus={[fileMenu]} />
246 <AttachmentDocumentBody
247 document={document}
248 name={params.name}
249 environmentId={environmentId}
250 htmlRender={params.htmlRender === "1"}
251 nativeViewer={nativeViewer}
252 nativeError={nativeError}
253 onOpenNative={() => {
254 setNativeViewer((current) => (current === null ? null : "open"));
255 setNativeOpen(true);
256 }}
257 />
258 {nativeOpen && nativeSource ? (
259 <FilePreviewModal
260 source={nativeSource}
261 onRequestClose={handleNativeClose}
262 onOpenError={handleNativeOpenError}
263 />
264 ) : null}
265 </View>
266 );
267}