1/* oxlint-disable react/no-array-index-key -- Captured table rows and columns have stable positions and may contain identical values. */
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import type { MenuAction } from "@react-native-menu/menu";
5import { EnvironmentId } from "@t3tools/contracts";
6import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { Alert, Platform, ScrollView, View } from "react-native";
9
10import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { AppText as Text } from "../../components/AppText";
12import { AudioFilePreview } from "../../components/AudioFilePreview";
13import { ControlPillMenu } from "../../components/ControlPill";
14import { EmptyState } from "../../components/EmptyState";
15import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
16import { useAttachmentDocument } from "../../lib/attachmentDocument";
17import { nativeViewerErrorMessage } from "../../lib/attachmentDownload";
18import { isFileBackedComposerAttachment } from "../../lib/composerImages";
19import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
20import { useUniwindTheme } from "../../lib/useUniwindTheme";
21import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts";
22import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
23import { FileMarkdownPreview } from "./FileMarkdownPreview";
24import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
25import { SourceFileSurface } from "./SourceFileSurface";
26import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
27import { HtmlRenderWebView } from "../threads/HtmlRenderWebView";
28
29/**
30 * Both the thread stack and the new-task sheet stack register this screen, so a chip in a
31 * sent message and a chip in a draft open the same view a workspace file does.
32 */
33export type AttachmentFileRouteParams = {
34 readonly environmentId?: string;
35 readonly threadId?: string;
36 readonly attachmentId: string;
37 readonly name: string;
38 readonly mimeType: string;
39 readonly sizeBytes: string;
40 /** Present for a draft attachment, which may still live only on this device. */
41 readonly draftKey?: string;
42 /** Present for a page an agent published with `html_render`, which takes the app theme. */
43 readonly htmlRender?: "1";
44};
45
46type AttachmentFileScreenProps = StaticScreenProps<AttachmentFileRouteParams>;
47
48/** Kinds this screen cannot render itself; the platform viewer is the primary presentation. */
49function nativeViewerKind(kind: ReturnType<typeof useAttachmentDocument>["kind"]) {
50 if (kind === "image") return "image" as const;
51 if (kind === "pdf") return "pdf" as const;
52 if (kind === "video" || kind === "unsupported") return "document" as const;
53 return null;
54}
55
56function AttachmentDocumentBody(props: {
57 readonly document: ReturnType<typeof useAttachmentDocument>;
58 readonly name: string;
59 readonly environmentId: EnvironmentId | null;
60 readonly htmlRender: boolean;
61 readonly nativeViewer: "pending" | "open" | "unavailable" | null;
62 readonly nativeError: string | null;
63 readonly onOpenNative: () => void;
64}) {
65 const { document } = props;
66 if (document.error) {
67 return (
68 <View className="flex-1 items-center justify-center bg-sheet px-6">
69 <EmptyState
70 title="File unavailable"
71 detail={document.error}
72 actionLabel="Try again"
73 onAction={document.retry}
74 />
75 </View>
76 );
77 }
78 if (props.nativeViewer !== null && props.nativeViewer !== "unavailable") {
79 return <FilePreviewLoading message="Opening in file viewer..." />;
80 }
81 if (!document.uri || (document.needsText && !document.content)) {
82 return <FilePreviewLoading message="Loading file..." />;
83 }
84 if (document.needsText && document.content) {
85 const { content, table } = document;
86 return (
87 <View className="flex-1 bg-sheet">
88 {content.truncated ? (
89 <FilePreviewNotice title="Partial file">
90 Preview limited to the first 1 MB. Save or share the file to read it in full.
91 </FilePreviewNotice>
92 ) : null}
93 {table && document.activeMode === "table" ? (
94 <ScrollView className="flex-1">
95 {table.truncated ? (
96 <FilePreviewNotice>
97 Table limited to the first 100 rows and 30 columns. Source shows the rest.
98 </FilePreviewNotice>
99 ) : null}
100 <ScrollView horizontal>
101 <View>
102 {table.rows.map((row, rowIndex) => (
103 <View
104 key={rowIndex}
105 className={rowIndex === 0 ? "flex-row bg-subtle" : "flex-row"}
106 >
107 {row.map((cell, columnIndex) => (
108 <View
109 key={columnIndex}
110 className="w-48 border-r border-b border-border px-3 py-2"
111 >
112 <Text
113 selectable
114 className={
115 rowIndex === 0
116 ? "text-sm font-t3-semibold text-foreground"
117 : "text-sm text-foreground"
118 }
119 >
120 {cell}
121 </Text>
122 </View>
123 ))}
124 </View>
125 ))}
126 </View>
127 </ScrollView>
128 </ScrollView>
129 ) : document.activeMode === "markdown" && props.environmentId ? (
130 <FileMarkdownPreview
131 cwd=""
132 relativePath=""
133 environmentId={props.environmentId}
134 threadId={null}
135 markdown={content.text}
136 captured
137 />
138 ) : (
139 <SourceFileSurface contents={content.text} path={props.name} selectable />
140 )}
141 </View>
142 );
143 }
144 if (document.kind === "audio") {
145 return <AudioFilePreview key={document.revision} uri={document.uri} onRetry={document.retry} />;
146 }
147 if (document.kind === "html") {
148 return props.htmlRender ? (
149 <HtmlRenderWebView key={document.uri} uri={document.uri} title={props.name} nested={false} />
150 ) : (
151 <WorkspaceFileWebPreview uri={document.uri} />
152 );
153 }
154 return (
155 <View className="flex-1 items-center justify-center bg-sheet px-6">
156 <EmptyState
157 title="No preview for this file"
158 detail={
159 props.nativeError ??
160 "No app on this device can show this format. Save or share it to open it elsewhere."
161 }
162 actionLabel="Try again"
163 onAction={props.onOpenNative}
164 />
165 </View>
166 );
167}
168
169export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
170 const navigation = useNavigation();
171 const { appearance, setCodeWordBreak } = useAppearancePreferences();
172 const iconColor = useUniwindTheme()["--color-icon"];
173 const isAndroid = Platform.OS === "android";
174 const params = props.route.params;
175 const environmentId = params.environmentId?.trim()
176 ? EnvironmentId.make(params.environmentId)
177 : null;
178 const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0;
179 const draftKey = params.draftKey ?? null;
180 const draft = useComposerDraft(draftKey);
181 const draftAttachment = draftKey
182 ? draft.attachments.find((entry) => entry.id === params.attachmentId)
183 : undefined;
184 const localAttachment =
185 draftAttachment && isFileBackedComposerAttachment(draftAttachment) ? draftAttachment : null;
186 const document = useAttachmentDocument({
187 name: params.name,
188 mimeType: params.mimeType,
189 sizeBytes,
190 attachmentId: params.attachmentId,
191 environmentId,
192 attachment: localAttachment,
193 });
194 const [nativeOpen, setNativeOpen] = useState(false);
195 // A format we cannot render goes straight to the system viewer; this screen is only the
196 // launch pad and, when no viewer can show it, the honest fallback.
197 const nativeKind = nativeViewerKind(document.kind);
198 const [nativeViewer, setNativeViewer] = useState<"pending" | "open" | "unavailable" | null>(
199 nativeKind ? "pending" : null,
200 );
201 const [nativeError, setNativeError] = useState<string | null>(null);
202 const pendingNativeError = useRef<string | null>(null);
203 const handleBack = useCallback(() => {
204 if (navigation.canGoBack()) navigation.goBack();
205 }, [navigation]);
206 const { uri, resource } = document;
207 useEffect(() => {
208 if (nativeViewer !== "pending" || !uri) return;
209 // oxlint-disable-next-line react/set-state-in-effect -- Presenting the viewer waits on the resolved file.
210 setNativeViewer("open");
211 setNativeOpen(true);
212 }, [nativeViewer, uri]);
213 // The viewer mints its own fresh URL from the resource, so a link that expired while this
214 // screen sat in the background is never handed to Quick Look or ACTION_VIEW.
215 const nativeSource = useMemo<FilePreviewSource | null>(() => {
216 const kind = nativeKind ?? "document";
217 const base = { kind, name: params.name, mimeType: params.mimeType };
218 if (localAttachment) return { ...base, attachment: localAttachment };
219 if (environmentId) return { ...base, environmentId, resource };
220 return uri ? { ...base, uri } : null;
221 }, [environmentId, localAttachment, nativeKind, params.mimeType, params.name, resource, uri]);
222 const handleNativeOpenError = useCallback((error: unknown) => {
223 pendingNativeError.current = nativeViewerErrorMessage(error);
224 }, []);
225 const handleNativeClose = useCallback(() => {
226 setNativeOpen(false);
227 const message = pendingNativeError.current;
228 pendingNativeError.current = null;
229 if (nativeViewer === null) {
230 // A file this screen renders itself: an explicit viewer failure is worth a word.
231 if (message) Alert.alert("Could not open document", message);
232 return;
233 }
234 if (message) {
235 setNativeError(message);
236 setNativeViewer("unavailable");
237 return;
238 }
239 // The viewer was the whole visit: return to the conversation rather than a blank screen.
240 handleBack();
241 }, [handleBack, nativeViewer]);
242 const removeFromDraft = useCallback(() => {
243 if (!draftKey) return;
244 removeComposerDraftAttachment(draftKey, params.attachmentId);
245 handleBack();
246 }, [draftKey, handleBack, params.attachmentId]);
247
248 const { content, renderedMode, activeMode, setRendered, share, sharing } = document;
249 const menuActions = useMemo(
250 () =>
251 [
252 renderedMode
253 ? ({
254 id: "preview",
255 title: renderedMode === "table" ? "Table" : "Preview",
256 icon: renderedMode === "table" ? "tablecells" : "eye",
257 inline: true,
258 onPress: () => setRendered(true),
259 } as const)
260 : null,
261 renderedMode
262 ? ({
263 id: "source",
264 title: "Source",
265 icon: "doc.text",
266 inline: true,
267 onPress: () => setRendered(false),
268 } as const)
269 : null,
270 content && activeMode === "source"
271 ? ({
272 id: "word-wrap",
273 title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap",
274 icon: "text.alignleft",
275 inline: false,
276 onPress: () => setCodeWordBreak(!appearance.codeWordBreak),
277 } as const)
278 : null,
279 content
280 ? ({
281 id: "copy",
282 title: content.truncated ? "Copy preview" : "Copy contents",
283 icon: "doc.on.doc",
284 inline: false,
285 onPress: () => copyTextWithHaptic(content.text),
286 } as const)
287 : null,
288 uri
289 ? ({
290 id: "share",
291 title: sharing ? "Opening share sheet…" : "Save or share",
292 icon: "square.and.arrow.up",
293 inline: false,
294 onPress: () => void share(),
295 } as const)
296 : null,
297 uri
298 ? ({
299 id: "open-viewer",
300 title: "Open in file viewer",
301 icon: "arrow.up.left.and.arrow.down.right",
302 inline: false,
303 onPress: () => {
304 setNativeViewer((current) => (current === null ? null : "open"));
305 setNativeOpen(true);
306 },
307 } as const)
308 : null,
309 draftKey
310 ? ({
311 id: "remove",
312 title: "Remove from draft",
313 icon: "trash",
314 inline: false,
315 destructive: true,
316 onPress: removeFromDraft,
317 } as const)
318 : null,
319 ].filter((action) => action !== null),
320 [
321 appearance.codeWordBreak,
322 setCodeWordBreak,
323 content,
324 draftKey,
325 removeFromDraft,
326 activeMode,
327 renderedMode,
328 setRendered,
329 share,
330 sharing,
331 uri,
332 ],
333 );
334 const selectedAction = activeMode === "source" ? "source" : "preview";
335 const androidMenuActions = useMemo<MenuAction[]>(
336 () =>
337 menuActions.map((action) => ({
338 id: action.id,
339 title: action.title,
340 image: action.icon,
341 state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined,
342 ...("destructive" in action ? { attributes: { destructive: true } } : {}),
343 })),
344 [selectedAction, menuActions],
345 );
346 const handleAndroidMenuAction = useCallback(
347 (event: { nativeEvent: { event: string } }) => {
348 menuActions.find(({ id }) => id === event.nativeEvent.event)?.onPress();
349 },
350 [menuActions],
351 );
352 const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}`;
353
354 return (
355 <View className="flex-1 bg-sheet">
356 <NativeStackScreenOptions
357 options={{
358 headerShown: !isAndroid,
359 headerTintColor: iconColor,
360 headerTitle: params.name,
361 title: params.name,
362 unstable_headerSubtitle: Platform.OS === "ios" ? subtitle : undefined,
363 }}
364 />
365 {isAndroid ? (
366 <AndroidScreenHeader
367 title={params.name}
368 subtitle={subtitle}
369 onBack={handleBack}
370 trailing={
371 <ControlPillMenu
372 actions={androidMenuActions}
373 isAnchoredToRight
374 title="File actions"
375 onPressAction={handleAndroidMenuAction}
376 >
377 <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" />
378 </ControlPillMenu>
379 }
380 />
381 ) : null}
382 <NativeHeaderToolbar placement="right">
383 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
384 {renderedMode ? (
385 <NativeHeaderToolbar.Menu inline>
386 {menuActions
387 .filter(({ inline }) => inline)
388 .map((action) => (
389 <NativeHeaderToolbar.MenuAction
390 key={action.id}
391 icon={action.icon}
392 isOn={action.id === selectedAction}
393 onPress={action.onPress}
394 >
395 {action.title}
396 </NativeHeaderToolbar.MenuAction>
397 ))}
398 </NativeHeaderToolbar.Menu>
399 ) : null}
400 {menuActions
401 .filter(({ inline }) => !inline)
402 .map((action) => (
403 <NativeHeaderToolbar.MenuAction
404 key={action.id}
405 icon={action.icon}
406 destructive={"destructive" in action}
407 onPress={action.onPress}
408 >
409 {action.title}
410 </NativeHeaderToolbar.MenuAction>
411 ))}
412 </NativeHeaderToolbar.Menu>
413 </NativeHeaderToolbar>
414 <AttachmentDocumentBody
415 document={document}
416 name={params.name}
417 environmentId={environmentId}
418 htmlRender={params.htmlRender === "1"}
419 nativeViewer={nativeViewer}
420 nativeError={nativeError}
421 onOpenNative={() => {
422 setNativeViewer((current) => (current === null ? null : "open"));
423 setNativeOpen(true);
424 }}
425 />
426 {nativeOpen && nativeSource ? (
427 <FilePreviewModal
428 source={nativeSource}
429 onRequestClose={handleNativeClose}
430 onOpenError={handleNativeOpenError}
431 />
432 ) : null}
433 </View>
434 );
435}