authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-31 16:23:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 16:23:40-07:00
logf8e4accf27415eb9f7f4106087d2a1b0186d66b9
tree92640b02ed6da9e78071cc50e0e1b4a75968a84e
parent6d15c5bbc3f3c331a7466bb94c17413a35f7b659
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add native image and PDF previews (#8959)

Co-authored-by: Julius Marminge <julius@mac.lan>

26 files changed, 1093 insertions(+), 371 deletions(-)

apps/mobile/app.config.ts+1
......@@ -206,6 +206,7 @@ const config: ExpoConfig = {
206206 },
207207 NSLocalNetworkUsageDescription:
208208 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
209 NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.",
209210 ITSAppUsesNonExemptEncryption: false,
210211 // The App Store screenshot harness rotates the iPad interface from
211212 // inside the app (CI denies osascript the Accessibility access that
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+34-2
......@@ -5,6 +5,7 @@ import UIKit
55public final class T3NativeControlsModule: Module {
66 private let presentationSources = T3PresentationSources()
77 private var videoPresentation: T3NativeVideoPresentation?
8 private var filePresentation: T3NativeFilePresentation?
89
910 public func definition() -> ModuleDefinition {
1011 Name("T3NativeControls")
......@@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module {
2324 self.dismissVideo(identifier: identifier)
2425 }.runOnQueue(.main)
2526
27 AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
28 try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
29 identifier: identifier, promise: promise)
30 }.runOnQueue(.main)
31
32 AsyncFunction("dismissFile") { (identifier: String) in
33 self.dismissFile(identifier: identifier)
34 }.runOnQueue(.main)
35
2636 OnDestroy {
2737 let presentation = self.videoPresentation
28 DispatchQueue.main.async { presentation?.dismiss() }
38 let file = self.filePresentation
39 DispatchQueue.main.async {
40 presentation?.dismiss()
41 file?.dismiss()
42 }
2943 }
3044
3145 View(T3PresentationSourceView.self) {
......@@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module {
140154 let isPlayableURL = url.isFileURL
141155 ? FileManager.default.isReadableFile(atPath: url.path)
142156 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
143 guard videoPresentation == nil,
157 guard videoPresentation == nil, filePresentation == nil,
144158 let presenter = appContext?.utilities?.currentViewController(),
145159 isPlayableURL
146160 else {
......@@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module {
162176 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
163177 }
164178
179 private func presentFile(url: URL, title: String, sourceIdentifier: String,
180 identifier: String, promise: Promise) throws {
181 guard filePresentation == nil, videoPresentation == nil,
182 let presenter = appContext?.utilities?.currentViewController()
183 else { throw URLError(.cannotLoadFromNetwork) }
184 let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources,
185 sourceIdentifier: sourceIdentifier) { [weak self] error in
186 self?.filePresentation = nil
187 if let error { promise.reject(error) } else { promise.resolve(nil) }
188 }
189 filePresentation = file
190 file.present(url: url, title: title, from: presenter)
191 }
192
193 private func dismissFile(identifier: String) {
194 if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
195 }
196
165197 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
166198 guard let presenter = appContext?.utilities?.currentViewController() else {
167199 throw NSError(
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift created+165
......@@ -0,0 +1,165 @@
1import ImageIO
2import QuickLook
3import UIKit
4import UniformTypeIdentifiers
5
6private final class FilePreviewItem: NSObject, QLPreviewItem {
7 var previewItemURL: URL?
8 var previewItemTitle: String?
9}
10
11private final class FilePreviewController: QLPreviewController {
12 var onAppear: (() -> Void)?
13
14 override func viewDidAppear(_ animated: Bool) {
15 super.viewDidAppear(animated)
16 onAppear?()
17 }
18}
19
20/// Quick Look owns image and document controls, zooming, and source-view transitions.
21final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource,
22 QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate {
23 let identifier: String
24 private var controller: UIViewController?
25 private let completion: (Error?) -> Void
26 private weak var sources: T3PresentationSources?
27 private let sourceIdentifier: String
28 private let item = FilePreviewItem()
29 private var loading: Task<Void, Never>?
30 private var dismissRequested = false
31 private var finished = false
32
33 init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) {
34 self.identifier = identifier
35 self.sources = sources
36 self.sourceIdentifier = sourceIdentifier
37 self.completion = completion
38 super.init()
39 }
40
41 func present(url: URL, title: String, from presenter: UIViewController) {
42 loading = Task { @MainActor [self] in
43 do {
44 let file = try await Self.prepareFile(url: url, title: title)
45 guard !finished, !Task.isCancelled else {
46 try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
47 return
48 }
49 item.previewItemURL = file
50 item.previewItemTitle = title
51 let preview = FilePreviewController()
52 preview.delegate = self
53 preview.dataSource = self
54 preview.onAppear = { [weak self] in self?.resumePendingDismissal() }
55 controller = preview
56 presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in
57 resumePendingDismissal()
58 }
59 preview.presentationController?.delegate = self
60 } catch {
61 finish(error: error)
62 }
63 }
64 }
65
66 func dismiss() {
67 dismissRequested = true
68 loading?.cancel()
69 guard !finished else { return }
70 guard let controller else { finish(); return }
71 // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal.
72 // Starting a second modal transition while UIKit is settling the first can strand it.
73 guard !controller.isBeingPresented, !controller.isBeingDismissed else { return }
74 controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() }
75 }
76
77 private func resumePendingDismissal() {
78 // Appearance callbacks run before UIKit has cleared the current transition.
79 DispatchQueue.main.async { [weak self] in
80 if self?.dismissRequested == true { self?.dismiss() }
81 }
82 }
83
84 func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 }
85
86 func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
87 item
88 }
89
90 func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? {
91 guard !UIAccessibility.isReduceMotionEnabled else { return nil }
92 return sources?.view(for: sourceIdentifier)
93 }
94
95 func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem,
96 inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect {
97 guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero }
98 view.pointee = source
99 return source.bounds
100 }
101
102 func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() }
103
104 func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() }
105
106 private func finish(error: Error? = nil) {
107 guard !finished else { return }
108 finished = true
109 loading?.cancel()
110 loading = nil
111 if let file = item.previewItemURL {
112 try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
113 }
114 item.previewItemURL = nil
115 DispatchQueue.main.async { [completion] in completion(error) }
116 }
117
118 /// Copy original bytes so preview and sharing do not mutate a draft or workspace file.
119 nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL {
120 try Task.checkCancellation()
121 let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)")
122 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
123 do {
124 let download = directory.appendingPathComponent("original")
125 if url.isFileURL {
126 try FileManager.default.copyItem(at: url, to: download)
127 } else if url.scheme == "data" {
128 try Data(contentsOf: url).write(to: download, options: .atomic)
129 } else {
130 guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else {
131 throw URLError(.unsupportedURL)
132 }
133 let (temporaryFile, response) = try await URLSession.shared.download(from: url)
134 guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else {
135 throw URLError(.badServerResponse)
136 }
137 try FileManager.default.moveItem(at: temporaryFile, to: download)
138 }
139 try Task.checkCancellation()
140 let type: UTType
141 if let image = CGImageSourceCreateWithURL(download as CFURL, nil),
142 CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image),
143 let detectedType = UTType(imageType as String) {
144 type = detectedType
145 } else if CGPDFDocument(download as CFURL) != nil {
146 type = .pdf
147 } else {
148 throw URLError(.cannotDecodeContentData)
149 }
150 let filename = URL(fileURLWithPath: title).lastPathComponent as NSString
151 let originalExtension = filename.pathExtension
152 let fileExtension = UTType(filenameExtension: originalExtension) == type
153 ? originalExtension : type.preferredFilenameExtension ?? "png"
154 let stem = filename.deletingPathExtension
155 var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_")
156 while name.utf8.count > 200 { name.removeLast() }
157 let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)")
158 try FileManager.default.moveItem(at: download, to: file)
159 return file
160 } catch {
161 try? FileManager.default.removeItem(at: directory)
162 throw error
163 }
164 }
165}
apps/mobile/src/components/ComposerAttachmentStrip.tsx+67-36
......@@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native";
66import { AppText as Text } from "./AppText";
77import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
88import { VideoAttachmentTile } from "./VideoAttachmentTile";
9import { loadLocalVideoPreview } from "../lib/localVideoPreview";
9import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
10import { PresentationSource } from "./NativePresentation";
11import type { FilePreviewSource } from "./FilePreviewModal";
12import { isPdfFile } from "../lib/filePreview";
1013
1114export interface ComposerAttachmentStripProps {
1215 /** Attachments to display. */
1316 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
1417 /** Called when the user removes an attachment. */
1518 readonly onRemove: (imageId: string) => void;
16 /** Called when the user taps on an image thumbnail to preview it. */
17 readonly onPressImage?: (previewUri: string) => void;
19 /** Called when the user taps an image or PDF to preview it. */
20 readonly onPressPreview?: (source: FilePreviewSource) => void;
1821 readonly onPressVideo?: (
1922 attachment: DraftComposerFileAttachment,
2023 sourceIdentifier: string,
......@@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: {
3235 readonly size: number;
3336 readonly borderRadius: number;
3437 readonly compact?: boolean;
35 readonly onPressImage?: (previewUri: string) => void;
38 readonly onPressPreview?: (source: FilePreviewSource) => void;
3639 readonly onPressVideo?: (
3740 attachment: DraftComposerFileAttachment,
3841 sourceIdentifier: string,
......@@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: {
4144 const { attachment } = props;
4245 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
4346 if (attachment.type === "image") {
47 const sourceIdentifier = `draft-image:${attachment.id}`;
4448 return (
45 <Pressable
46 onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined}
47 >
48 <Image
49 source={{ uri: attachment.previewUri }}
50 style={style}
51 className="bg-subtle"
52 resizeMode="cover"
53 />
54 </Pressable>
49 <PresentationSource identifier={sourceIdentifier}>
50 <Pressable
51 accessibilityRole="imagebutton"
52 accessibilityLabel={`Open ${attachment.name}`}
53 disabled={!props.onPressPreview}
54 onPress={() =>
55 props.onPressPreview?.({
56 kind: "image",
57 uri: attachment.dataUrl,
58 name: attachment.name,
59 sourceIdentifier,
60 })
61 }
62 >
63 <Image
64 source={{ uri: attachment.previewUri }}
65 style={style}
66 className="bg-subtle"
67 resizeMode="cover"
68 />
69 </Pressable>
70 </PresentationSource>
5571 );
5672 }
5773 const onPressVideo = props.onPressVideo;
......@@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: {
6076 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />
6177 );
6278 }
79 const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;
80 const sourceIdentifier = `draft-file:${attachment.id}`;
6381 return (
64 <View
65 className={
66 props.compact
67 ? "items-center justify-center bg-subtle"
68 : "items-center justify-center gap-1 bg-subtle px-2"
69 }
70 style={style}
71 >
72 <SymbolView
73 name="doc.text"
74 size={props.compact ? 15 : 22}
75 tintColor="#a3a3a3"
76 type="monochrome"
77 />
78 {!props.compact ? (
79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
80 {attachment.name}
81 </Text>
82 ) : null}
83 </View>
82 <PresentationSource identifier={sourceIdentifier}>
83 <Pressable
84 accessibilityRole="button"
85 accessibilityLabel={`Open ${attachment.name}`}
86 disabled={!canPreview}
87 onPress={() =>
88 props.onPressPreview?.({
89 kind: "pdf",
90 name: attachment.name,
91 attachment,
92 sourceIdentifier,
93 })
94 }
95 className={
96 props.compact
97 ? "items-center justify-center bg-subtle"
98 : "items-center justify-center gap-1 bg-subtle px-2"
99 }
100 style={style}
101 >
102 <SymbolView
103 name="doc.text"
104 size={props.compact ? 15 : 22}
105 tintColor="#a3a3a3"
106 type="monochrome"
107 />
108 {!props.compact ? (
109 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
110 {attachment.name}
111 </Text>
112 ) : null}
113 </Pressable>
114 </PresentationSource>
84115 );
85116}
86117
......@@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: {
113144 shareRef.current = controller;
114145 setSharing(true);
115146 void (async () => {
116 const preview = await loadLocalVideoPreview(attachment, controller.signal);
147 const preview = await loadLocalAttachmentPreview(attachment, controller.signal);
117148 if (!preview) return;
118149 try {
119150 await preview.share(controller.signal, sourceIdentifier);
......@@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
185216 attachment={attachment}
186217 size={size}
187218 borderRadius={radius}
188 onPressImage={props.onPressImage}
219 onPressPreview={props.onPressPreview}
189220 onPressVideo={props.onPressVideo}
190221 />
191222 <Pressable
apps/mobile/src/components/FilePreview.ios.tsx created+43
......@@ -0,0 +1,43 @@
1import { requireNativeModule } from "expo";
2import { useEffect, useEffectEvent, useId } from "react";
3import { Alert } from "react-native";
4
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6
7const NativeControls = requireNativeModule<{
8 presentFile(
9 uri: string,
10 name: string,
11 sourceIdentifier: string,
12 identifier: string,
13 ): Promise<void>;
14 dismissFile(identifier: string): Promise<void>;
15}>("T3NativeControls");
16
17export function FilePreview(props: {
18 readonly source: ResolvedFilePreviewSource;
19 readonly onRequestClose: () => void;
20}) {
21 const { uri, name, sourceIdentifier } = props.source;
22 const identifier = useId();
23 const onRequestClose = useEffectEvent(props.onRequestClose);
24
25 useEffect(() => {
26 let canceled = false;
27 void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier)
28 .catch(() => {
29 if (!canceled) {
30 Alert.alert("Could not open preview", "The file could not be loaded. Please try again.");
31 }
32 })
33 .finally(() => {
34 if (!canceled) onRequestClose();
35 });
36 return () => {
37 canceled = true;
38 void NativeControls.dismissFile(identifier).catch(() => undefined);
39 };
40 }, [uri, name, sourceIdentifier, identifier]);
41
42 return null;
43}
apps/mobile/src/components/FilePreview.tsx created+52
......@@ -0,0 +1,52 @@
1import { useEffect, useEffectEvent } from "react";
2import { Alert } from "react-native";
3import ImageViewing from "react-native-image-viewing";
4
5import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7
8function PdfPreview(props: {
9 readonly source: ResolvedFilePreviewSource;
10 readonly onRequestClose: () => void;
11}) {
12 const { uri, name } = props.source;
13 const onRequestClose = useEffectEvent(props.onRequestClose);
14 useEffect(() => {
15 const controller = new AbortController();
16 const input = {
17 attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" },
18 signal: controller.signal,
19 };
20 // Android's system chooser supplies the installed PDF apps.
21 const opened =
22 uri.startsWith("file:") || uri.startsWith("content:")
23 ? shareLocalAttachment({ ...input, uri })
24 : downloadAndShareAttachment({ ...input, url: uri });
25 void opened
26 .catch(() => {
27 if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again.");
28 })
29 .finally(() => {
30 if (!controller.signal.aborted) onRequestClose();
31 });
32 return () => controller.abort();
33 }, [uri, name]);
34 return null;
35}
36
37export function FilePreview(props: {
38 readonly source: ResolvedFilePreviewSource;
39 readonly onRequestClose: () => void;
40}) {
41 if (props.source.kind === "pdf") return <PdfPreview {...props} />;
42 return (
43 <ImageViewing
44 images={[{ uri: props.source.uri }]}
45 imageIndex={0}
46 visible
47 onRequestClose={props.onRequestClose}
48 swipeToCloseEnabled
49 doubleTapToZoomEnabled
50 />
51 );
52}
apps/mobile/src/components/FilePreviewModal.tsx created+93
......@@ -0,0 +1,93 @@
1import { useIsFocused } from "@react-navigation/native";
2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";
4import { Alert, Keyboard } from "react-native";
5
6import type { DraftComposerFileAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";
10import { FilePreview } from "./FilePreview";
11
12export interface ResolvedFilePreviewSource {
13 readonly kind: "image" | "pdf";
14 readonly uri: string;
15 readonly name?: string;
16 readonly sourceIdentifier?: string;
17}
18
19export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
20 (
21 | { readonly uri: string }
22 | { readonly attachment: DraftComposerFileAttachment }
23 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
24 );
25
26function ResolvedFilePreview(props: {
27 readonly source: FilePreviewSource;
28 readonly onRequestClose: () => void;
29}) {
30 const { source } = props;
31 const environmentId = "environmentId" in source ? source.environmentId : null;
32 const connection = usePreparedConnection(environmentId);
33 const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null);
34 // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer.
35 const [uri, setUri] = useState<string | null>("uri" in source ? source.uri : null);
36 const onRequestClose = useEffectEvent(props.onRequestClose);
37 const failed =
38 environmentId !== null &&
39 uri === null &&
40 (connection._tag === "None" || asset._tag === "Failure");
41 useEffect(() => Keyboard.dismiss(), []);
42 useEffect(() => {
43 if (uri === null && asset._tag === "Success") setUri(asset.url);
44 }, [uri, asset]);
45 useEffect(() => {
46 if (!failed) return;
47 Alert.alert("Could not open preview", "Reconnect to this environment and try again.");
48 onRequestClose();
49 }, [failed]);
50 useEffect(() => {
51 if (!("attachment" in source)) return;
52 const controller = new AbortController();
53 let release: (() => void) | undefined;
54 void loadLocalAttachmentPreview(source.attachment, controller.signal)
55 .then((file) => {
56 if (!file) return;
57 if (controller.signal.aborted) {
58 file.dispose();
59 return;
60 }
61 release = file.dispose;
62 setUri(file.uri);
63 })
64 .catch(() => {
65 if (controller.signal.aborted) return;
66 Alert.alert("Could not open preview", "Attach the file again and retry.");
67 onRequestClose();
68 });
69 return () => {
70 controller.abort();
71 release?.();
72 };
73 }, [source]);
74
75 return uri === null ? null : (
76 <FilePreview source={{ ...source, uri }} onRequestClose={props.onRequestClose} />
77 );
78}
79
80export function FilePreviewModal(props: {
81 readonly source: FilePreviewSource | null;
82 readonly onRequestClose: () => void;
83}) {
84 const isFocused = useIsFocused();
85 const hasSource = props.source !== null;
86 const onRequestClose = useEffectEvent(props.onRequestClose);
87 useEffect(() => {
88 if (!isFocused && hasSource) onRequestClose();
89 }, [isFocused, hasSource]);
90
91 if (!props.source || !isFocused) return null;
92 return <ResolvedFilePreview source={props.source} onRequestClose={props.onRequestClose} />;
93}
apps/mobile/src/components/VideoPreviewModal.ios.tsx+2-2
......@@ -4,7 +4,7 @@ import { requireNativeModule } from "expo";
44import { useEffect, useEffectEvent, useId, useState } from "react";
55import { Alert, Keyboard } from "react-native";
66
7import { loadLocalVideoPreview } from "../lib/localVideoPreview";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
88import { useAssetUrlState } from "../state/assets";
99import { usePreparedConnection } from "../state/session";
1010import type { VideoPreviewSource } from "./VideoPreviewModal";
......@@ -67,7 +67,7 @@ function NativeVideoPreview(props: {
6767 void (async () => {
6868 const file =
6969 source.type === "local"
70 ? await loadLocalVideoPreview(source.attachment, controller.signal)
70 ? await loadLocalAttachmentPreview(source.attachment, controller.signal)
7171 : null;
7272 if (source.type === "local" && !file) return;
7373 try {
apps/mobile/src/components/VideoPreviewModal.tsx+2-2
......@@ -20,7 +20,7 @@ import {
2020 type AttachmentPreviewFile,
2121} from "../lib/attachmentDownload";
2222import type { DraftComposerFileAttachment } from "../lib/composerImages";
23import { loadLocalVideoPreview } from "../lib/localVideoPreview";
23import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
2424import { useAssetUrlState } from "../state/assets";
2525import { usePreparedConnection } from "../state/session";
2626import { SymbolView } from "./AppSymbol";
......@@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: {
155155 setFailure(null);
156156 const loading =
157157 source.type === "local"
158 ? loadLocalVideoPreview(source.attachment, controller.signal)
158 ? loadLocalAttachmentPreview(source.attachment, controller.signal)
159159 : downloadAttachmentForPreview({
160160 url: downloadUrl!,
161161 attachment: { name: attachment.name, mimeType },
apps/mobile/src/components/VideoThumbnailImage.tsx+2-2
......@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
55import { StyleSheet } from "react-native";
66
77import type { DraftComposerFileAttachment } from "../lib/composerImages";
8import { loadLocalVideoPreview } from "../lib/localVideoPreview";
8import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
99import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";
1010
1111export function VideoThumbnailImage(props: {
......@@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: {
2525 async (signal) =>
2626 typeof source === "string"
2727 ? { uri: source, dispose: () => undefined }
28 : loadLocalVideoPreview(source, signal),
28 : loadLocalAttachmentPreview(source, signal),
2929 controller.signal,
3030 ).then((thumbnail) => {
3131 if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail });
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+30-1
......@@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol";
1717import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1818import { ControlPillMenu } from "../../components/ControlPill";
1919import { EmptyState } from "../../components/EmptyState";
20import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2021import { LoadingScreen } from "../../components/LoadingScreen";
2122import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
2223import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24import { isPdfFile } from "../../lib/filePreview";
2325import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2426import { useUniwindTheme } from "../../lib/useUniwindTheme";
2527import { useThreadSelection } from "../../state/use-thread-selection";
......@@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
487489 readonly mode: FileViewMode;
488490 } | null>(null);
489491 const [previewRevision, setPreviewRevision] = useState(0);
492 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
490493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
491494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
492495 const canPreview =
......@@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
586589 inline: false,
587590 onPress: () => copyTextWithHaptic(relativePath),
588591 } as const,
592 isPdfFile({ name: relativePath }) && previewUri !== null
593 ? ({
594 id: "open-pdf",
595 title: "Open PDF",
596 icon: "arrow.up.left.and.arrow.down.right",
597 inline: false,
598 onPress: () =>
599 setFullScreenPreview({
600 kind: "pdf",
601 uri: previewUri,
602 name: basename(relativePath),
603 }),
604 } as const)
605 : null,
589606 isBrowserFile && typeof assetPreviewUri === "string"
590607 ? ({
591608 id: "open-browser",
......@@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
605622 } as const)
606623 : null,
607624 ].filter((action) => action !== null);
608 }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]);
625 }, [
626 assetPreviewUri,
627 previewUri,
628 canPreview,
629 isBrowserFile,
630 isImageFile,
631 relativePath,
632 resolvedActiveMode,
633 ]);
609634
610635 const androidFileMenuActions = useMemo<MenuAction[]>(
611636 () =>
......@@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
766791 truncated={fileData?.truncated ?? false}
767792 onRefresh={() => fileQuery.refresh()}
768793 />
794 <FilePreviewModal
795 source={fullScreenPreview}
796 onRequestClose={() => setFullScreenPreview(null)}
797 />
769798 </View>
770799 </ReviewHighlighterProvider>
771800 );
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+26-23
......@@ -1,24 +1,25 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState } from "react";
2import { useId, useMemo, useState } from "react";
33import { ActivityIndicator, Image, Pressable, View } from "react-native";
4import ImageViewing from "react-native-image-viewing";
54import { AsyncResult } from "effect/unstable/reactivity";
65
76import { AppText as Text } from "../../components/AppText";
87import { EmptyState } from "../../components/EmptyState";
98import { workspaceFileImageAtom } from "./workspace-file-image-cache";
9import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
10import { PresentationSource } from "../../components/NativePresentation";
1011
1112function ResolvedWorkspaceFileImagePreview(props: {
1213 readonly accessibilityLabel: string;
1314 readonly uri: string;
1415}) {
1516 const [loadError, setLoadError] = useState<string | null>(null);
16 const [fullScreenVisible, setFullScreenVisible] = useState(false);
17 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
18 const sourceIdentifier = useId();
1719 const imageSource = useMemo(
1820 () => ({ uri: props.uri, cache: "force-cache" as const }),
1921 [props.uri],
2022 );
21 const fullScreenImages = useMemo(() => [imageSource], [imageSource]);
2223
2324 return (
2425 <View className="relative flex-1 bg-subtle">
......@@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: {
2728 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
2829 disabled={loadError !== null}
2930 className="flex-1 p-4 active:bg-subtle-strong"
30 onPress={() => setFullScreenVisible(true)}
31 onPress={() =>
32 setPreview({
33 kind: "image",
34 uri: props.uri,
35 name: props.accessibilityLabel,
36 sourceIdentifier,
37 })
38 }
3139 >
32 <Image
33 accessible={false}
34 source={imageSource}
35 className="h-full w-full"
36 resizeMode="contain"
37 onLoadStart={() => setLoadError(null)}
38 onError={(event) => {
39 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
40 }}
41 />
40 <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
41 <Image
42 accessible={false}
43 source={imageSource}
44 className="h-full w-full"
45 resizeMode="contain"
46 onLoadStart={() => setLoadError(null)}
47 onError={(event) => {
48 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
49 }}
50 />
51 </PresentationSource>
4252 </Pressable>
4353
4454 {loadError !== null ? (
......@@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
4757 </View>
4858 ) : null}
4959
50 <ImageViewing
51 images={fullScreenImages}
52 imageIndex={0}
53 visible={fullScreenVisible}
54 onRequestClose={() => setFullScreenVisible(false)}
55 swipeToCloseEnabled
56 doubleTapToZoomEnabled
57 />
60 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
5861 </View>
5962 );
6063}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-11
......@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
55import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
66import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
8import ImageViewing from "react-native-image-viewing";
8import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
99
1010import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1111import { SymbolView } from "../../components/AppSymbol";
......@@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
5353 Record<string, ReadonlyArray<ReviewHighlightedToken>>
5454 >({});
5555 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
56 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
56 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
5757
5858 const selectedLines = useMemo(
5959 () => (target ? getSelectedReviewCommentLines(target) : []),
......@@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
272272 attachments={attachments}
273273 imageBorderRadius={16}
274274 imageSize={60}
275 onPressImage={setPreviewImageUri}
275 onPressPreview={setPreviewFile}
276276 removeButtonPlacement="gutter"
277277 onRemove={(imageId) => {
278278 setAttachments((current) =>
......@@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
332332 </View>
333333 </KeyboardStickyView>
334334 ) : null}
335 <ImageViewing
336 images={previewImageUri ? [{ uri: previewImageUri }] : []}
337 imageIndex={0}
338 visible={previewImageUri !== null}
339 onRequestClose={() => setPreviewImageUri(null)}
340 swipeToCloseEnabled
341 doubleTapToZoomEnabled
342 />
335 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
343336 </View>
344337 );
345338}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21-5
......@@ -35,6 +35,7 @@ import {
3535import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3636import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
3737import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
38import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3839import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
3940import { ProviderIcon } from "../../components/ProviderIcon";
4041import { SymbolView } from "../../components/AppSymbol";
......@@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: {
167168 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
168169 const [isComposerFocused, setIsComposerFocused] = useState(false);
169170 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
170 const wasFocusedBeforeVideoRef = useRef(false);
171 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
172 const wasFocusedBeforePreviewRef = useRef(false);
171173 const openVideoPreview = useCallback(
172174 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
173 wasFocusedBeforeVideoRef.current = isComposerFocused;
175 wasFocusedBeforePreviewRef.current = isComposerFocused;
176 setPreviewFile(null);
174177 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
175178 },
176179 [isComposerFocused],
177180 );
178 const closeVideoPreview = useCallback(() => {
181 const openFilePreview = useCallback(
182 (source: FilePreviewSource) => {
183 wasFocusedBeforePreviewRef.current = isComposerFocused;
184 setPreviewVideo(null);
185 setPreviewFile((current) => current ?? source);
186 },
187 [isComposerFocused],
188 );
189 const closeMediaPreview = useCallback(() => {
179190 setPreviewVideo(null);
180 if (wasFocusedBeforeVideoRef.current) {
191 setPreviewFile(null);
192 if (wasFocusedBeforePreviewRef.current) {
181193 setTimeout(() => {
182194 if (navigation.isFocused()) promptInputRef.current?.focus();
183195 }, 100);
......@@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: {
12121224 ? () => undefined
12131225 : flow.removeAttachment
12141226 }
1227 onPressPreview={
1228 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
1229 }
12151230 onPressVideo={
12161231 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
12171232 }
......@@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: {
13181333 </ComposerDictationToolbar>
13191334 </Animated.View>
13201335 </ComposerSurface>
1321 <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} />
1336 <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} />
1337 <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} />
13221338 </View>
13231339 );
13241340
apps/mobile/src/features/threads/ThreadComposer.tsx+10-17
......@@ -20,7 +20,7 @@ import {
2020 type RefObject,
2121} from "react";
2222import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
23import ImageViewing from "react-native-image-viewing";
23import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2424import Animated, {
2525 FadeIn,
2626 FadeInDown,
......@@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
311311 const inFlightThreadIdsRef = useRef(new Set<string>());
312312 const { onExpandedChange } = props;
313313
314 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
314 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
315315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
316316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317317 const showStopAction =
......@@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
372372 onExpandedChange?.(isExpanded);
373373 }, [isExpanded, onExpandedChange]);
374374
375 const onPressImage = useCallback(
376 (uri: string) => {
375 const onPressPreview = useCallback(
376 (source: FilePreviewSource) => {
377377 wasExpandedBeforePreviewRef.current = isFocused;
378378 setPreviewVideo(null);
379 setPreviewImageUri(uri);
379 setPreviewFile((current) => current ?? source);
380380 },
381381 [isFocused],
382382 );
383383
384384 const closePreview = useCallback(() => {
385 setPreviewImageUri(null);
385 setPreviewFile(null);
386386 setPreviewVideo(null);
387387 if (wasExpandedBeforePreviewRef.current) {
388388 setTimeout(() => {
......@@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
394394 const onPressVideo = useCallback(
395395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
396396 wasExpandedBeforePreviewRef.current = isFocused;
397 setPreviewImageUri(null);
397 setPreviewFile(null);
398398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
399399 },
400400 [isFocused],
......@@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
619619 <ComposerAttachmentStrip
620620 attachments={props.draftAttachments}
621621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622 onPressImage={voiceInput.isBusy ? undefined : onPressImage}
622 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
623623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
624624 />
625625 </Animated.View>
......@@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
673673 size={30}
674674 borderRadius={8}
675675 compact
676 onPressImage={onPressImage}
676 onPressPreview={onPressPreview}
677677 onPressVideo={onPressVideo}
678678 />
679679 ))}
......@@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
819819 </Animated.View>
820820
821821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
822 <ImageViewing
823 images={previewImageUri ? [{ uri: previewImageUri }] : []}
824 imageIndex={0}
825 visible={previewImageUri !== null}
826 onRequestClose={closePreview}
827 swipeToCloseEnabled
828 doubleTapToZoomEnabled
829 />
822 <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
830823 </Animated.View>
831824 );
832825});
apps/mobile/src/features/threads/ThreadFeed.tsx+152-79
......@@ -36,6 +36,7 @@ import {
3636 useMemo,
3737 useRef,
3838 useState,
39 useId,
3940 type ReactNode,
4041 type RefObject,
4142} from "react";
......@@ -62,8 +63,9 @@ import {
6263 View,
6364 type ViewStyle,
6465} from "react-native";
65import { TouchableOpacity } from "react-native-gesture-handler";
66import ImageViewing from "react-native-image-viewing";
66import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
67import { isPdfFile } from "../../lib/filePreview";
68import { PresentationSource } from "../../components/NativePresentation";
6769import { useSafeAreaInsets } from "react-native-safe-area-context";
6870import Animated, {
6971 FadeIn,
......@@ -212,9 +214,11 @@ export interface ThreadFeedProps {
212214function MessageAttachmentImage(props: {
213215 readonly environmentId: EnvironmentId;
214216 readonly attachmentId: string;
217 readonly name: string;
215218 readonly className: string;
216 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
219 readonly onPressPreview: (source: FilePreviewSource) => void;
217220}) {
221 const sourceIdentifier = useId();
218222 const uri = useAssetUrl(props.environmentId, {
219223 _tag: "attachment",
220224 attachmentId: props.attachmentId,
......@@ -229,9 +233,17 @@ function MessageAttachmentImage(props: {
229233 }
230234
231235 return (
232 <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>
233 <Image source={{ uri }} className={props.className} resizeMode="cover" />
234 </TouchableOpacity>
236 <PresentationSource identifier={sourceIdentifier}>
237 <Pressable
238 accessibilityRole="imagebutton"
239 accessibilityLabel={`Open ${props.name}`}
240 onPress={() =>
241 props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier })
242 }
243 >
244 <Image source={{ uri }} className={props.className} resizeMode="cover" />
245 </Pressable>
246 </PresentationSource>
235247 );
236248}
237249
......@@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt
249261function MessageAttachmentFile(props: {
250262 readonly environmentId: EnvironmentId;
251263 readonly attachment: ChatFileAttachment;
264 readonly onPressPreview: (source: FilePreviewSource) => void;
252265 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
253266}) {
267 const sourceIdentifier = useId();
254268 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
255269 reportFailure: false,
256270 });
257271 const preparedConnection = usePreparedConnection(props.environmentId);
258272 const { attachment } = props;
259273 const videoType = videoMimeType(attachment);
274 const isPdf = isPdfFile(attachment);
275 const fileTypeLabel = isPdf
276 ? "PDF"
277 : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File");
278 const sizeLabel = formatAttachmentSize(attachment.sizeBytes);
260279 const thumbnailUrl = useAssetUrl(
261280 props.environmentId,
262281 videoType === null
......@@ -348,26 +367,63 @@ function MessageAttachmentFile(props: {
348367 }
349368
350369 return (
351 <Pressable
352 accessibilityRole="button"
353 accessibilityLabel={`Open ${attachment.name}`}
354 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
355 disabled={opening || httpBaseUrl === null}
356 className="flex-row items-center gap-2 py-1"
357 onPress={() => shareFile()}
370 <PresentationSource
371 identifier={sourceIdentifier}
372 className="my-1"
373 style={{ width: 280, maxWidth: "100%" }}
358374 >
359 {opening ? (
360 <ActivityIndicator size="small" />
361 ) : (
362 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
363 )}
364 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
365 {attachment.name}
366 </Text>
367 <Text className="text-xs text-foreground-muted">
368 {formatAttachmentSize(attachment.sizeBytes)}
369 </Text>
370 </Pressable>
375 <Pressable
376 accessibilityRole="button"
377 accessibilityLabel={`Open ${attachment.name}`}
378 accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}
379 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
380 disabled={opening || httpBaseUrl === null}
381 className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"
382 onPress={() =>
383 isPdf
384 ? props.onPressPreview({
385 kind: "pdf",
386 name: attachment.name,
387 environmentId: props.environmentId,
388 resource: {
389 _tag: "attachment",
390 attachmentId: attachment.id,
391 fileName: attachment.name,
392 mimeType: "application/pdf",
393 },
394 sourceIdentifier,
395 })
396 : shareFile(sourceIdentifier)
397 }
398 >
399 <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle">
400 {opening ? (
401 <ActivityIndicator size="small" />
402 ) : (
403 <SymbolView
404 name="doc.text"
405 size={26}
406 tintColorClassName={isPdf ? "accent-red-500" : "accent-foreground-muted"}
407 type="monochrome"
408 />
409 )}
410 </View>
411 <View className="min-w-0 flex-1 gap-1">
412 <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}>
413 {attachment.name}
414 </Text>
415 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
416 {fileTypeLabel} · {sizeLabel}
417 </Text>
418 </View>
419 <SymbolView
420 name="chevron.right"
421 size={12}
422 tintColorClassName="accent-foreground-muted"
423 type="monochrome"
424 />
425 </Pressable>
426 </PresentationSource>
371427 );
372428}
373429
......@@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: {
391447 readonly sourceKey: string;
392448 readonly unavailable: boolean;
393449 readonly alt: string | null;
394 readonly onPressImage: (uri: string) => void;
450 readonly onPressPreview: (source: FilePreviewSource) => void;
395451}) {
452 const sourceIdentifier = useId();
396453 const [availableWidth, setAvailableWidth] = useState(0);
397454 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
398455 const [failedUri, setFailedUri] = useState<string | null>(null);
......@@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: {
437494 )}
438495 </View>
439496 ) : (
440 <TouchableOpacity
441 accessibilityRole="imagebutton"
442 accessibilityLabel={props.alt ?? "Markdown image"}
443 activeOpacity={0.7}
444 onPress={() => props.onPressImage(props.uri!)}
445 style={{ alignSelf: "flex-start" }}
446 >
447 <View
448 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
449 style={{
450 ...frameStyle,
451 }}
497 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
498 <Pressable
499 accessibilityRole="imagebutton"
500 accessibilityLabel={props.alt ?? "Markdown image"}
501 onPress={() =>
502 props.onPressPreview({
503 kind: "image",
504 uri: props.uri!,
505 name: props.alt ?? "Image",
506 sourceIdentifier,
507 })
508 }
509 style={{ alignSelf: "flex-start" }}
452510 >
453 <ThreadMarkdownImageRequest
454 key={props.uri}
455 uri={props.uri}
456 onLoad={setSourceSize}
457 onError={() => setFailedUri(props.uri)}
458 />
459 </View>
460 </TouchableOpacity>
511 <View
512 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
513 style={{
514 ...frameStyle,
515 }}
516 >
517 <ThreadMarkdownImageRequest
518 key={props.uri}
519 uri={props.uri}
520 onLoad={setSourceSize}
521 onError={() => setFailedUri(props.uri)}
522 />
523 </View>
524 </Pressable>
525 </PresentationSource>
461526 )}
462527 {props.alt ? (
463528 <Text selectable className="text-xs text-foreground-muted">
......@@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: {
506571 readonly threadId: ThreadId;
507572 readonly path: string;
508573 readonly alt: string | null;
509 readonly onPressImage: (uri: string) => void;
574 readonly onPressPreview: (source: FilePreviewSource) => void;
510575}) {
511576 const assetUrl = useAssetUrlState(props.environmentId, {
512577 _tag: "workspace-file",
......@@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: {
520585 sourceKey={props.path}
521586 unavailable={assetUrl._tag === "Failure"}
522587 alt={props.alt}
523 onPressImage={props.onPressImage}
588 onPressPreview={props.onPressPreview}
524589 />
525590 );
526591}
......@@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null })
532597 sourceKey="unavailable"
533598 unavailable
534599 alt={props.alt}
535 onPressImage={() => undefined}
600 onPressPreview={() => undefined}
536601 />
537602 );
538603}
......@@ -1264,7 +1329,7 @@ function renderFeedEntry(
12641329 readonly onToggleWorkGroup: (groupId: string) => void;
12651330 readonly onToggleWorkRow: (rowId: string) => void;
12661331 readonly onToggleTurnFold: (turnId: TurnId) => void;
1267 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
1332 readonly onPressPreview: (source: FilePreviewSource) => void;
12681333 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
12691334 readonly onMarkdownLinkPress: (href: string) => void;
12701335 readonly renderMarkdownImage: MarkdownImageRenderer;
......@@ -1375,14 +1440,16 @@ function renderFeedEntry(
13751440 key={attachment.id}
13761441 environmentId={props.environmentId}
13771442 attachmentId={attachment.id}
1443 name={attachment.name}
13781444 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
1379 onPressImage={props.onPressImage}
1445 onPressPreview={props.onPressPreview}
13801446 />
13811447 ) : isFileAttachment(attachment) ? (
13821448 <MessageAttachmentFile
13831449 key={attachment.id}
13841450 environmentId={props.environmentId}
13851451 attachment={attachment}
1452 onPressPreview={props.onPressPreview}
13861453 onPressVideo={props.onPressVideo}
13871454 />
13881455 ) : (
......@@ -1436,14 +1503,16 @@ function renderFeedEntry(
14361503 key={attachment.id}
14371504 environmentId={props.environmentId}
14381505 attachmentId={attachment.id}
1506 name={attachment.name}
14391507 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
1440 onPressImage={props.onPressImage}
1508 onPressPreview={props.onPressPreview}
14411509 />
14421510 ) : isFileAttachment(attachment) ? (
14431511 <MessageAttachmentFile
14441512 key={attachment.id}
14451513 environmentId={props.environmentId}
14461514 attachment={attachment}
1515 onPressPreview={props.onPressPreview}
14471516 onPressVideo={props.onPressVideo}
14481517 />
14491518 ) : (
......@@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18061875 expandedTurnIds: new Set(),
18071876 });
18081877 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
1809 const [expandedImage, setExpandedImage] = useState<{
1810 uri: string;
1811 headers?: Record<string, string>;
1812 } | null>(null);
1878 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
18131879 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
18141880 useEffect(() => {
18151881 setExpandedVideo(null);
1882 setExpandedFile(null);
18161883 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
18171884 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
18181885 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
......@@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18581925 );
18591926 if (relativePath) {
18601927 void Haptics.selectionAsync();
1928 if (isPdfFile({ name: relativePath })) {
1929 setExpandedFile(
1930 (current) =>
1931 current ?? {
1932 kind: "pdf",
1933 name: relativePath.split("/").at(-1),
1934 environmentId: props.environmentId,
1935 resource: {
1936 _tag: "workspace-file",
1937 threadId: props.threadId,
1938 path: relativePath,
1939 },
1940 },
1941 );
1942 return;
1943 }
18611944 navigation.navigate("ThreadFile", {
18621945 environmentId: String(props.environmentId),
18631946 threadId: String(props.threadId),
......@@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18691952 }
18701953
18711954 if (presentation.href) {
1955 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
1956 setExpandedFile(
1957 (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },
1958 );
1959 return;
1960 }
18721961 void tryOpenExternalUrl(presentation.href, "markdown-link");
18731962 }
18741963 },
......@@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18841973 sourceKey={imageSource.uri}
18851974 unavailable={false}
18861975 alt={image.alt}
1887 onPressImage={(uri) => setExpandedImage({ uri })}
1976 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
18881977 />
18891978 );
18901979 }
......@@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18971986 threadId={props.threadId}
18981987 path={imageSource.path}
18991988 alt={image.alt}
1900 onPressImage={(uri) => setExpandedImage({ uri })}
1989 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
19011990 />
19021991 );
19031992 },
......@@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22852374 [suspendEndScrollMaintenanceForDisclosure],
22862375 );
22872376
2288 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
2289 setExpandedImage({ uri, headers });
2377 const onPressPreview = useCallback((source: FilePreviewSource) => {
2378 setExpandedFile((current) => current ?? source);
22902379 }, []);
22912380 const onPressVideo = useCallback(
22922381 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
......@@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23502439 onToggleWorkGroup,
23512440 onToggleWorkRow,
23522441 onToggleTurnFold,
2353 onPressImage,
2442 onPressPreview,
23542443 onPressVideo,
23552444 onMarkdownLinkPress,
23562445 renderMarkdownImage,
......@@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23792468 userBubbleMaxWidth,
23802469 onCopyWorkRow,
23812470 onMarkdownLinkPress,
2382 onPressImage,
2471 onPressPreview,
23832472 onPressVideo,
23842473 onToggleTurnFold,
23852474 onToggleWorkGroup,
......@@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25652654 </View>
25662655
25672656 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2568 <ImageViewing
2569 images={
2570 expandedImage
2571 ? [
2572 {
2573 uri: expandedImage.uri,
2574 headers: expandedImage.headers,
2575 },
2576 ]
2577 : []
2578 }
2579 imageIndex={0}
2580 visible={expandedImage !== null}
2581 onRequestClose={() => setExpandedImage(null)}
2582 swipeToCloseEnabled
2583 doubleTapToZoomEnabled
2584 />
2657 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
25852658 </>
25862659 );
25872660});
apps/mobile/src/lib/composerFiles.test.ts+112
......@@ -1,4 +1,5 @@
11import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
23import type { ImagePickerAsset } from "expo-image-picker";
34
45const mocks = vi.hoisted(() => ({
......@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
910 delete: vi.fn(),
1011 open: vi.fn(),
1112 size: vi.fn(),
13 readBase64: vi.fn(),
1214}));
1315
1416vi.mock("expo-file-system", () => {
......@@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => {
5557 mocks.copy(this.uri, destination.uri);
5658 }
5759
60 async base64(): Promise<string> {
61 return mocks.readBase64(this.uri);
62 }
63
5864 delete(): void {
5965 mocks.delete(this.uri);
6066 }
......@@ -95,9 +101,115 @@ describe("composer file attachments", () => {
95101 mocks.delete.mockReset();
96102 mocks.open.mockReset();
97103 mocks.size.mockReset();
104 mocks.readBase64.mockReset();
98105 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
99106 });
100107
108 describe("photo library image conversion", () => {
109 const jpeg = "/9j/2Q==";
110 const photo: ImagePickerAsset = {
111 uri: "file:///picker/photo.heic",
112 type: "image",
113 fileName: "photo.HEIC",
114 mimeType: "image/heic",
115 fileSize: 20 * 1024 * 1024,
116 base64: jpeg,
117 width: 1,
118 height: 1,
119 };
120
121 it.each(["image/heic", "image/heif", undefined])(
122 "attaches the native JPEG conversion with matching metadata when the source MIME is %s",
123 async (mimeType) => {
124 mocks.pickMedia.mockResolvedValue({
125 canceled: false,
126 assets: [{ ...photo, mimeType }],
127 });
128
129 const result = await pickComposerImages({ existingCount: 0 });
130
131 expect(result).toEqual({
132 images: [
133 {
134 id: "attachment-id",
135 type: "image",
136 name: "photo.jpg",
137 mimeType: "image/jpeg",
138 sizeBytes: 4,
139 dataUrl: `data:image/jpeg;base64,${jpeg}`,
140 previewUri: `data:image/jpeg;base64,${jpeg}`,
141 },
142 ],
143 error: null,
144 });
145 },
146 );
147
148 it.each([
149 { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },
150 { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },
151 { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },
152 ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => {
153 const name = `photo.${original.extension}`;
154 mocks.pickMedia.mockResolvedValue({
155 canceled: false,
156 assets: [{ ...photo, fileName: name, mimeType: original.mimeType }],
157 });
158 mocks.readBase64.mockResolvedValue(original.base64);
159
160 const result = await pickComposerImages({ existingCount: 0 });
161
162 expect(result.error).toBeNull();
163 expect(result.images).toEqual([
164 expect.objectContaining({
165 name,
166 mimeType: original.mimeType,
167 dataUrl: `data:${original.mimeType};base64,${original.base64}`,
168 sizeBytes: Buffer.from(original.base64, "base64").byteLength,
169 }),
170 ]);
171 });
172
173 it("checks the converted JPEG size even when the HEIC source was smaller", async () => {
174 const oversized =
175 jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4);
176 mocks.pickMedia.mockResolvedValue({
177 canceled: false,
178 assets: [{ ...photo, fileSize: 42, base64: oversized }],
179 });
180
181 await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
182 images: [],
183 error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
184 });
185 });
186
187 it("does not relabel unconverted HEIC bytes as JPEG", async () => {
188 mocks.pickMedia.mockResolvedValue({
189 canceled: false,
190 assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }],
191 });
192
193 const result = await pickComposerImages({ existingCount: 0 });
194
195 expect(result.images).toEqual([]);
196 expect(result.error).toContain("not a supported image type");
197 });
198
199 it("retains a converted photo when another original cannot be read", async () => {
200 mocks.pickMedia.mockResolvedValue({
201 canceled: false,
202 assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],
203 });
204 mocks.readBase64.mockRejectedValue(new Error("missing file"));
205
206 const result = await pickComposerImages({ existingCount: 0 });
207
208 expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);
209 expect(result.error).toBe("Failed to read 'missing.gif'.");
210 });
211 });
212
101213 describe("photo library videos", () => {
102214 const image: ImagePickerAsset = {
103215 uri: "file:///picker/photo.png",
apps/mobile/src/lib/composerImages.ts+37-11
......@@ -351,7 +351,7 @@ export async function pickComposerMedia(input: {
351351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
352352 break;
353353 }
354 const mimeType = asset.mimeType?.toLowerCase();
354 let mimeType = asset.mimeType?.toLowerCase();
355355 if (asset.type === "video" || mimeType?.startsWith("video/")) {
356356 if (input.maxVideoBytes === undefined) {
357357 error = "Video attachments are unavailable here.";
......@@ -375,35 +375,61 @@ export async function pickComposerMedia(input: {
375375 }
376376 continue;
377377 }
378 if (!mimeType?.startsWith("image/")) {
378 if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
379379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
380380 continue;
381381 }
382 if (!isProviderSendTurnSupportedImageMimeType(mimeType)) {
383 error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
384 continue;
385 }
386382
387 const base64 = asset.base64;
383 let base64 = asset.base64;
388384 if (!base64) {
389385 error = `Failed to read '${asset.fileName ?? "image"}'.`;
390386 continue;
391387 }
392388
393 const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64);
389 let name = asset.fileName?.trim() || "image";
390 // The iOS picker returns JPEG base64 even when its metadata describes HEIC,
391 // PNG, or GIF. Keep supported originals so transparency and animation survive;
392 // use the native JPEG conversion for formats providers cannot accept.
393 if (base64.startsWith("/9j/")) {
394 if (
395 mimeType &&
396 mimeType !== "image/jpeg" &&
397 isProviderSendTurnSupportedImageMimeType(mimeType)
398 ) {
399 try {
400 const { File } = await import("expo-file-system");
401 base64 = await new File(asset.uri).base64();
402 } catch {
403 error = `Failed to read '${name}'.`;
404 continue;
405 }
406 } else {
407 mimeType = "image/jpeg";
408 if (!/\.jpe?g$/i.test(name)) {
409 name = `${name.replace(/\.[^.]+$/, "")}.jpg`;
410 }
411 }
412 }
413 if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) {
414 error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
415 continue;
416 }
417
418 const sizeBytes = estimateBase64ByteSize(base64);
394419 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
395420 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
396421 continue;
397422 }
398423
424 const dataUrl = `data:${mimeType};base64,${base64}`;
399425 attachments.push({
400426 id: uuidv4(),
401427 type: "image",
402 name: asset.fileName ?? "image",
428 name,
403429 mimeType,
404430 sizeBytes,
405 dataUrl: `data:${mimeType};base64,${base64}`,
406 previewUri: asset.uri,
431 dataUrl,
432 previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl,
407433 });
408434 }
409435
apps/mobile/src/lib/filePreview.test.ts created+17
......@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isPdfFile } from "./filePreview";
4
5describe("PDF preview detection", () => {
6 it.each([
7 [{ name: "download", mimeType: "application/pdf" }, true],
8 [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true],
9 [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true],
10 [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true],
11 [{ name: "report.pdf", mimeType: "text/plain" }, false],
12 [{ name: "report.pdf.exe" }, false],
13 [{ name: "https://example.com/page?download=report.pdf" }, false],
14 ])("classifies %j as %s", (file, expected) => {
15 expect(isPdfFile(file)).toBe(expected);
16 });
17});
apps/mobile/src/lib/filePreview.ts created+6
......@@ -0,0 +1,6 @@
1/** MIME metadata wins; use the extension for files reported without a specific type. */
2export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean {
3 const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
4 if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf";
5 return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? "");
6}
apps/mobile/src/lib/localAttachmentPreview.test.ts created+127
......@@ -0,0 +1,127 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 retain: vi.fn(),
5 share: vi.fn(),
6 exists: vi.fn(),
7}));
8
9vi.mock("../state/use-composer-drafts", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
13vi.mock("expo-file-system", () => ({
14 File: class {
15 constructor(readonly uri: string) {}
16 get exists(): boolean {
17 return mocks.exists(this.uri);
18 }
19 },
20 Paths: {
21 document: {
22 uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/",
23 },
24 },
25}));
26
27import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
28
29const attachment = {
30 type: "file" as const,
31 id: "draft-video",
32 name: "clip.mov",
33 mimeType: "video/quicktime",
34 sizeBytes: 12,
35 fileUri:
36 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov",
37};
38
39beforeEach(() => {
40 mocks.retain.mockReset();
41 mocks.share.mockReset();
42 mocks.exists.mockReset();
43 mocks.retain.mockImplementation(() => vi.fn());
44 mocks.exists.mockReturnValue(true);
45 mocks.share.mockResolvedValue(undefined);
46});
47
48describe("loadLocalAttachmentPreview", () => {
49 it("retains and shares a PDF with its original filename and type", async () => {
50 const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };
51 const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);
52 await preview!.share(new AbortController().signal);
53 expect(mocks.share).toHaveBeenCalledWith(
54 expect.objectContaining({
55 attachment: { name: "report.pdf", mimeType: "application/pdf" },
56 }),
57 );
58 expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
59 preview!.dispose();
60 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
61 });
62 it("resolves the current iOS container and releases its playback lease once", async () => {
63 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
64 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
65 expect(mocks.retain).toHaveBeenCalledWith(attachment);
66 const release = mocks.retain.mock.results[0]!.value;
67 expect(release).not.toHaveBeenCalled();
68 preview?.dispose();
69 preview?.dispose();
70 expect(release).toHaveBeenCalledTimes(1);
71 });
72
73 it.each([undefined, "share-button"])(
74 "keeps a separate share lease after playback closes (source: %s)",
75 async (sourceIdentifier) => {
76 const shared = Promise.withResolvers<void>();
77 mocks.share.mockReturnValue(shared.promise);
78 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
79 const share = preview!.share(new AbortController().signal, sourceIdentifier);
80 expect(mocks.retain).toHaveBeenCalledTimes(2);
81 const releasePlayback = mocks.retain.mock.results[0]!.value;
82 const releaseShare = mocks.retain.mock.results[1]!.value;
83 preview!.dispose();
84 expect(releasePlayback).toHaveBeenCalledTimes(1);
85 expect(releaseShare).not.toHaveBeenCalled();
86 shared.resolve();
87 await share;
88 expect(releaseShare).toHaveBeenCalledTimes(1);
89 },
90 );
91
92 it("releases a failed share while keeping playback retained", async () => {
93 mocks.share.mockRejectedValue(new Error("Sharing unavailable"));
94 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
95 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(
96 "Sharing unavailable",
97 );
98 expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1);
99 expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
100 preview!.dispose();
101 });
102
103 it("releases a load canceled during native module loading", async () => {
104 const controller = new AbortController();
105 const loading = loadLocalAttachmentPreview(attachment, controller.signal);
106 controller.abort();
107 await expect(loading).resolves.toBeNull();
108 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
109 expect(mocks.exists).not.toHaveBeenCalled();
110 });
111
112 it("reports missing files and releases their lease", async () => {
113 mocks.exists.mockReturnValue(false);
114 await expect(
115 loadLocalAttachmentPreview(attachment, new AbortController().signal),
116 ).rejects.toThrow("This attachment is no longer available. Attach the file again.");
117 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
118 });
119
120 it("does not start sharing a disposed preview", async () => {
121 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
122 preview!.dispose();
123 await preview!.share(new AbortController().signal);
124 expect(mocks.share).not.toHaveBeenCalled();
125 expect(mocks.retain).toHaveBeenCalledTimes(1);
126 });
127});
apps/mobile/src/lib/localAttachmentPreview.ts created+59
......@@ -0,0 +1,59 @@
1import { videoMimeType } from "@t3tools/shared/video";
2
3import type { DraftComposerFileAttachment } from "./composerImages";
4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
7
8/** Retains the draft original for preview and gives each outgoing share its own lease. */
9export async function loadLocalAttachmentPreview(
10 attachment: DraftComposerFileAttachment,
11 signal: AbortSignal,
12): Promise<AttachmentPreviewFile | null> {
13 if (signal.aborted) return null;
14 const release = retainComposerAttachmentFileForPreview(attachment);
15 try {
16 const { File, Paths } = await import("expo-file-system");
17 if (signal.aborted) {
18 release();
19 return null;
20 }
21 const uri =
22 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
23 attachment.fileUri;
24 const file = new File(uri);
25 if (!file.exists) {
26 throw new Error("The local attachment file is missing.");
27 }
28 let disposed = false;
29 return {
30 uri: file.uri,
31 dispose: () => {
32 if (disposed) return;
33 disposed = true;
34 release();
35 },
36 share: async (shareSignal, sourceIdentifier) => {
37 if (disposed || shareSignal.aborted) return;
38 const releaseShare = retainComposerAttachmentFileForPreview(attachment);
39 try {
40 await shareLocalAttachment({
41 uri: file.uri,
42 attachment: {
43 name: attachment.name,
44 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
45 },
46 signal: shareSignal,
47 sourceIdentifier,
48 });
49 } finally {
50 releaseShare();
51 }
52 },
53 };
54 } catch (cause) {
55 release();
56 if (signal.aborted) return null;
57 throw new Error("This attachment is no longer available. Attach the file again.", { cause });
58 }
59}
apps/mobile/src/lib/localVideoPreview.test.ts deleted-114
......@@ -1,114 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 retain: vi.fn(),
5 share: vi.fn(),
6 exists: vi.fn(),
7}));
8
9vi.mock("../state/use-composer-drafts", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
13vi.mock("expo-file-system", () => ({
14 File: class {
15 constructor(readonly uri: string) {}
16 get exists(): boolean {
17 return mocks.exists(this.uri);
18 }
19 },
20 Paths: {
21 document: {
22 uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/",
23 },
24 },
25}));
26
27import { loadLocalVideoPreview } from "./localVideoPreview";
28
29const attachment = {
30 type: "file" as const,
31 id: "draft-video",
32 name: "clip.mov",
33 mimeType: "video/quicktime",
34 sizeBytes: 12,
35 fileUri:
36 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov",
37};
38
39beforeEach(() => {
40 mocks.retain.mockReset();
41 mocks.share.mockReset();
42 mocks.exists.mockReset();
43 mocks.retain.mockImplementation(() => vi.fn());
44 mocks.exists.mockReturnValue(true);
45 mocks.share.mockResolvedValue(undefined);
46});
47
48describe("loadLocalVideoPreview", () => {
49 it("resolves the current iOS container and releases its playback lease once", async () => {
50 const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
51 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
52 expect(mocks.retain).toHaveBeenCalledWith(attachment);
53 const release = mocks.retain.mock.results[0]!.value;
54 expect(release).not.toHaveBeenCalled();
55 preview?.dispose();
56 preview?.dispose();
57 expect(release).toHaveBeenCalledTimes(1);
58 });
59
60 it.each([undefined, "share-button"])(
61 "keeps a separate share lease after playback closes (source: %s)",
62 async (sourceIdentifier) => {
63 const shared = Promise.withResolvers<void>();
64 mocks.share.mockReturnValue(shared.promise);
65 const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
66 const share = preview!.share(new AbortController().signal, sourceIdentifier);
67 expect(mocks.retain).toHaveBeenCalledTimes(2);
68 const releasePlayback = mocks.retain.mock.results[0]!.value;
69 const releaseShare = mocks.retain.mock.results[1]!.value;
70 preview!.dispose();
71 expect(releasePlayback).toHaveBeenCalledTimes(1);
72 expect(releaseShare).not.toHaveBeenCalled();
73 shared.resolve();
74 await share;
75 expect(releaseShare).toHaveBeenCalledTimes(1);
76 },
77 );
78
79 it("releases a failed share while keeping playback retained", async () => {
80 mocks.share.mockRejectedValue(new Error("Sharing unavailable"));
81 const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
82 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(
83 "Sharing unavailable",
84 );
85 expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1);
86 expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
87 preview!.dispose();
88 });
89
90 it("releases a load canceled during native module loading", async () => {
91 const controller = new AbortController();
92 const loading = loadLocalVideoPreview(attachment, controller.signal);
93 controller.abort();
94 await expect(loading).resolves.toBeNull();
95 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
96 expect(mocks.exists).not.toHaveBeenCalled();
97 });
98
99 it("reports missing files and releases their lease", async () => {
100 mocks.exists.mockReturnValue(false);
101 await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow(
102 "This video is no longer available. Attach the file again.",
103 );
104 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
105 });
106
107 it("does not start sharing a disposed preview", async () => {
108 const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
109 preview!.dispose();
110 await preview!.share(new AbortController().signal);
111 expect(mocks.share).not.toHaveBeenCalled();
112 expect(mocks.retain).toHaveBeenCalledTimes(1);
113 });
114});
apps/mobile/src/lib/localVideoPreview.ts deleted-59
......@@ -1,59 +0,0 @@
1import { videoMimeType } from "@t3tools/shared/video";
2
3import type { DraftComposerFileAttachment } from "./composerImages";
4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
7
8/** Retains the draft original for playback and gives each outgoing share its own lease. */
9export async function loadLocalVideoPreview(
10 attachment: DraftComposerFileAttachment,
11 signal: AbortSignal,
12): Promise<AttachmentPreviewFile | null> {
13 if (signal.aborted) return null;
14 const release = retainComposerAttachmentFileForPreview(attachment);
15 try {
16 const { File, Paths } = await import("expo-file-system");
17 if (signal.aborted) {
18 release();
19 return null;
20 }
21 const uri =
22 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
23 attachment.fileUri;
24 const file = new File(uri);
25 if (!file.exists) {
26 throw new Error("The local attachment file is missing.");
27 }
28 let disposed = false;
29 return {
30 uri: file.uri,
31 dispose: () => {
32 if (disposed) return;
33 disposed = true;
34 release();
35 },
36 share: async (shareSignal, sourceIdentifier) => {
37 if (disposed || shareSignal.aborted) return;
38 const releaseShare = retainComposerAttachmentFileForPreview(attachment);
39 try {
40 await shareLocalAttachment({
41 uri: file.uri,
42 attachment: {
43 name: attachment.name,
44 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
45 },
46 signal: shareSignal,
47 sourceIdentifier,
48 });
49 } finally {
50 releaseShare();
51 }
52 },
53 };
54 } catch (cause) {
55 release();
56 if (signal.aborted) return null;
57 throw new Error("This video is no longer available. Attach the file again.", { cause });
58 }
59}
docs/internals/mobile-navigation.md+22-5
......@@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround.
5151
5252## Native media presentations
5353
54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's
55full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child
54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit,
55image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child
5656and pass the stable identifier to the presentation. The registry keeps weak
5757references to source views; recycled or compact composer thumbnails can register
5858the same identifier. Identifiers must distinguish simultaneously visible attachments.
59This source registration does not own playback and can be reused by a future image
60viewer. Android uses a regular view.
59The source registration does not own the preview. Android uses a regular view.
6160
6261On iOS, video previews mount `AVPlayerViewController` temporarily inside the
6362registered source and enter full screen through AVKit. AVKit
......@@ -67,7 +66,25 @@ simulator, that leaves native Close unable to exit full screen. When the source
6766the player uses a standard modal presentation. Programmatic entry uses the same
6867guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
6968if that selector is unavailable, the player also falls back to a standard modal.
70Images retain their existing viewer.
69
70`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset,
71or a retained composer file. On iOS, Quick Look owns image and document layout, controls,
72zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail
73and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or
74another image scroll view over that presentation: Quick Look coordinates its image gestures
75with the return to the thumbnail. Missing sources use the standard transition, and Reduce
76Motion disables animation. A pending programmatic Close waits until the current presentation
77or cancelled dismissal has settled before starting another transition.
78
79The shared native presenter copies original bytes into its own temporary directory and
80removes that copy after dismissal. Network downloads write to disk, and sharing never edits
81the source attachment. Draft images use their stored upload data rather than a potentially
82expired picker URI. No React Navigation route or custom transition animator is needed.
83
84The same viewer handles message images, markdown images, PDF attachments and links,
85composer thumbnails, and workspace image previews. The workspace PDF web preview has an
86Open PDF action for the native viewer. Android retains its image viewer and uses the
87system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission.
7188
7289Received videos open directly from their signed asset URL. AVKit handles buffering;
7390the client does not download the entire file or show a separate opening overlay before
docs/user/composer.md+9-2
......@@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and **
2020Videos use the server's file upload limit. You can also share photos, videos, and files into
2121T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
2222sends, so queued messages keep their files until they deliver. Select a received file on mobile
23to save it or open it in another app through the system share sheet.
23to preview it or open the system share options.
24
25Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail
26into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return.
27Use Share to save a copy or send it to another app. PDFs support page navigation and search.
28PDF links in assistant responses open the same preview. On Android, images open in the image
29viewer and PDFs open the system chooser.
2430
2531Select a video attachment before or after sending to play it. Web and desktop use the browser's
2632built-in controls. On mobile, videos open in a full-screen player with native playback controls.
......@@ -36,7 +42,8 @@ On web and desktop, if you reload before a file finishes uploading, the draft ke
3642and shows **Attach again** next to it. Attach the file again or remove it, then send.
3743
3844On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
39the composer or paste them into a message.
45the composer or paste them into a message. On iOS, selecting them from **Photo Library** also
46converts them to JPEG. The 10 MB image limit applies to the converted photo.
4047
4148On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
4249GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list