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 = {...@@ -206,6 +206,7 @@ const config: ExpoConfig = {
206 },206 },
207 NSLocalNetworkUsageDescription:207 NSLocalNetworkUsageDescription:
208 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",208 "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.",
209 ITSAppUsesNonExemptEncryption: false,210 ITSAppUsesNonExemptEncryption: false,
210 // The App Store screenshot harness rotates the iPad interface from211 // The App Store screenshot harness rotates the iPad interface from
211 // inside the app (CI denies osascript the Accessibility access that212 // 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...@@ -5,6 +5,7 @@ import UIKit
5public final class T3NativeControlsModule: Module {5public final class T3NativeControlsModule: Module {
6 private let presentationSources = T3PresentationSources()6 private let presentationSources = T3PresentationSources()
7 private var videoPresentation: T3NativeVideoPresentation?7 private var videoPresentation: T3NativeVideoPresentation?
8 private var filePresentation: T3NativeFilePresentation?
89
9 public func definition() -> ModuleDefinition {10 public func definition() -> ModuleDefinition {
10 Name("T3NativeControls")11 Name("T3NativeControls")
...@@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module {...@@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module {
23 self.dismissVideo(identifier: identifier)24 self.dismissVideo(identifier: identifier)
24 }.runOnQueue(.main)25 }.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
26 OnDestroy {36 OnDestroy {
27 let presentation = self.videoPresentation37 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 }
29 }43 }
3044
31 View(T3PresentationSourceView.self) {45 View(T3PresentationSourceView.self) {
...@@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module {...@@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module {
140 let isPlayableURL = url.isFileURL154 let isPlayableURL = url.isFileURL
141 ? FileManager.default.isReadableFile(atPath: url.path)155 ? FileManager.default.isReadableFile(atPath: url.path)
142 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)156 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
143 guard videoPresentation == nil,157 guard videoPresentation == nil, filePresentation == nil,
144 let presenter = appContext?.utilities?.currentViewController(),158 let presenter = appContext?.utilities?.currentViewController(),
145 isPlayableURL159 isPlayableURL
146 else {160 else {
...@@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module {...@@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module {
162 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }176 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
163 }177 }
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
165 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {197 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
166 guard let presenter = appContext?.utilities?.currentViewController() else {198 guard let presenter = appContext?.utilities?.currentViewController() else {
167 throw NSError(199 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";...@@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native";
6import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
8import { VideoAttachmentTile } from "./VideoAttachmentTile";8import { 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
11export interface ComposerAttachmentStripProps {14export interface ComposerAttachmentStripProps {
12 /** Attachments to display. */15 /** Attachments to display. */
13 readonly attachments: ReadonlyArray<DraftComposerAttachment>;16 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
14 /** Called when the user removes an attachment. */17 /** Called when the user removes an attachment. */
15 readonly onRemove: (imageId: string) => void;18 readonly onRemove: (imageId: string) => void;
16 /** Called when the user taps on an image thumbnail to preview it. */19 /** Called when the user taps an image or PDF to preview it. */
17 readonly onPressImage?: (previewUri: string) => void;20 readonly onPressPreview?: (source: FilePreviewSource) => void;
18 readonly onPressVideo?: (21 readonly onPressVideo?: (
19 attachment: DraftComposerFileAttachment,22 attachment: DraftComposerFileAttachment,
20 sourceIdentifier: string,23 sourceIdentifier: string,
...@@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: {...@@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: {
32 readonly size: number;35 readonly size: number;
33 readonly borderRadius: number;36 readonly borderRadius: number;
34 readonly compact?: boolean;37 readonly compact?: boolean;
35 readonly onPressImage?: (previewUri: string) => void;38 readonly onPressPreview?: (source: FilePreviewSource) => void;
36 readonly onPressVideo?: (39 readonly onPressVideo?: (
37 attachment: DraftComposerFileAttachment,40 attachment: DraftComposerFileAttachment,
38 sourceIdentifier: string,41 sourceIdentifier: string,
...@@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: {...@@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: {
41 const { attachment } = props;44 const { attachment } = props;
42 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };45 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
43 if (attachment.type === "image") {46 if (attachment.type === "image") {
47 const sourceIdentifier = `draft-image:${attachment.id}`;
44 return (48 return (
45 <Pressable49 <PresentationSource identifier={sourceIdentifier}>
46 onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined}50 <Pressable
47 >51 accessibilityRole="imagebutton"
48 <Image52 accessibilityLabel={`Open ${attachment.name}`}
49 source={{ uri: attachment.previewUri }}53 disabled={!props.onPressPreview}
50 style={style}54 onPress={() =>
51 className="bg-subtle"55 props.onPressPreview?.({
52 resizeMode="cover"56 kind: "image",
53 />57 uri: attachment.dataUrl,
54 </Pressable>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>
55 );71 );
56 }72 }
57 const onPressVideo = props.onPressVideo;73 const onPressVideo = props.onPressVideo;
...@@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: {...@@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: {
60 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />76 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />
61 );77 );
62 }78 }
79 const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;
80 const sourceIdentifier = `draft-file:${attachment.id}`;
63 return (81 return (
64 <View82 <PresentationSource identifier={sourceIdentifier}>
65 className={83 <Pressable
66 props.compact84 accessibilityRole="button"
67 ? "items-center justify-center bg-subtle"85 accessibilityLabel={`Open ${attachment.name}`}
68 : "items-center justify-center gap-1 bg-subtle px-2"86 disabled={!canPreview}
69 }87 onPress={() =>
70 style={style}88 props.onPressPreview?.({
71 >89 kind: "pdf",
72 <SymbolView90 name: attachment.name,
73 name="doc.text"91 attachment,
74 size={props.compact ? 15 : 22}92 sourceIdentifier,
75 tintColor="#a3a3a3"93 })
76 type="monochrome"94 }
77 />95 className={
78 {!props.compact ? (96 props.compact
79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>97 ? "items-center justify-center bg-subtle"
80 {attachment.name}98 : "items-center justify-center gap-1 bg-subtle px-2"
81 </Text>99 }
82 ) : null}100 style={style}
83 </View>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>
84 );115 );
85}116}
86117
...@@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: {...@@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: {
113 shareRef.current = controller;144 shareRef.current = controller;
114 setSharing(true);145 setSharing(true);
115 void (async () => {146 void (async () => {
116 const preview = await loadLocalVideoPreview(attachment, controller.signal);147 const preview = await loadLocalAttachmentPreview(attachment, controller.signal);
117 if (!preview) return;148 if (!preview) return;
118 try {149 try {
119 await preview.share(controller.signal, sourceIdentifier);150 await preview.share(controller.signal, sourceIdentifier);
...@@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {...@@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
185 attachment={attachment}216 attachment={attachment}
186 size={size}217 size={size}
187 borderRadius={radius}218 borderRadius={radius}
188 onPressImage={props.onPressImage}219 onPressPreview={props.onPressPreview}
189 onPressVideo={props.onPressVideo}220 onPressVideo={props.onPressVideo}
190 />221 />
191 <Pressable222 <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";...@@ -4,7 +4,7 @@ import { requireNativeModule } from "expo";
4import { useEffect, useEffectEvent, useId, useState } from "react";4import { useEffect, useEffectEvent, useId, useState } from "react";
5import { Alert, Keyboard } from "react-native";5import { Alert, Keyboard } from "react-native";
66
7import { loadLocalVideoPreview } from "../lib/localVideoPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";9import { usePreparedConnection } from "../state/session";
10import type { VideoPreviewSource } from "./VideoPreviewModal";10import type { VideoPreviewSource } from "./VideoPreviewModal";
...@@ -67,7 +67,7 @@ function NativeVideoPreview(props: {...@@ -67,7 +67,7 @@ function NativeVideoPreview(props: {
67 void (async () => {67 void (async () => {
68 const file =68 const file =
69 source.type === "local"69 source.type === "local"
70 ? await loadLocalVideoPreview(source.attachment, controller.signal)70 ? await loadLocalAttachmentPreview(source.attachment, controller.signal)
71 : null;71 : null;
72 if (source.type === "local" && !file) return;72 if (source.type === "local" && !file) return;
73 try {73 try {
apps/mobile/src/components/VideoPreviewModal.tsx+2-2
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20 type AttachmentPreviewFile,20 type AttachmentPreviewFile,
21} from "../lib/attachmentDownload";21} from "../lib/attachmentDownload";
22import type { DraftComposerFileAttachment } from "../lib/composerImages";22import type { DraftComposerFileAttachment } from "../lib/composerImages";
23import { loadLocalVideoPreview } from "../lib/localVideoPreview";23import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
24import { useAssetUrlState } from "../state/assets";24import { useAssetUrlState } from "../state/assets";
25import { usePreparedConnection } from "../state/session";25import { usePreparedConnection } from "../state/session";
26import { SymbolView } from "./AppSymbol";26import { SymbolView } from "./AppSymbol";
...@@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: {...@@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: {
155 setFailure(null);155 setFailure(null);
156 const loading =156 const loading =
157 source.type === "local"157 source.type === "local"
158 ? loadLocalVideoPreview(source.attachment, controller.signal)158 ? loadLocalAttachmentPreview(source.attachment, controller.signal)
159 : downloadAttachmentForPreview({159 : downloadAttachmentForPreview({
160 url: downloadUrl!,160 url: downloadUrl!,
161 attachment: { name: attachment.name, mimeType },161 attachment: { name: attachment.name, mimeType },
apps/mobile/src/components/VideoThumbnailImage.tsx+2-2
...@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";...@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
5import { StyleSheet } from "react-native";5import { StyleSheet } from "react-native";
66
7import type { DraftComposerFileAttachment } from "../lib/composerImages";7import type { DraftComposerFileAttachment } from "../lib/composerImages";
8import { loadLocalVideoPreview } from "../lib/localVideoPreview";8import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
9import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";9import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";
1010
11export function VideoThumbnailImage(props: {11export function VideoThumbnailImage(props: {
...@@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: {...@@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: {
25 async (signal) =>25 async (signal) =>
26 typeof source === "string"26 typeof source === "string"
27 ? { uri: source, dispose: () => undefined }27 ? { uri: source, dispose: () => undefined }
28 : loadLocalVideoPreview(source, signal),28 : loadLocalAttachmentPreview(source, signal),
29 controller.signal,29 controller.signal,
30 ).then((thumbnail) => {30 ).then((thumbnail) => {
31 if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail });31 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";...@@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol";
17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { ControlPillMenu } from "../../components/ControlPill";18import { ControlPillMenu } from "../../components/ControlPill";
19import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
20import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
20import { LoadingScreen } from "../../components/LoadingScreen";21import { LoadingScreen } from "../../components/LoadingScreen";
21import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";22import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
22import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";23import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24import { isPdfFile } from "../../lib/filePreview";
23import { tryOpenExternalUrl } from "../../lib/openExternalUrl";25import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
24import { useUniwindTheme } from "../../lib/useUniwindTheme";26import { useUniwindTheme } from "../../lib/useUniwindTheme";
25import { useThreadSelection } from "../../state/use-thread-selection";27import { useThreadSelection } from "../../state/use-thread-selection";
...@@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
487 readonly mode: FileViewMode;489 readonly mode: FileViewMode;
488 } | null>(null);490 } | null>(null);
489 const [previewRevision, setPreviewRevision] = useState(0);491 const [previewRevision, setPreviewRevision] = useState(0);
492 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
490 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
491 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
492 const canPreview =495 const canPreview =
...@@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
586 inline: false,589 inline: false,
587 onPress: () => copyTextWithHaptic(relativePath),590 onPress: () => copyTextWithHaptic(relativePath),
588 } as const,591 } 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,
589 isBrowserFile && typeof assetPreviewUri === "string"606 isBrowserFile && typeof assetPreviewUri === "string"
590 ? ({607 ? ({
591 id: "open-browser",608 id: "open-browser",
...@@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
605 } as const)622 } as const)
606 : null,623 : null,
607 ].filter((action) => action !== null);624 ].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
610 const androidFileMenuActions = useMemo<MenuAction[]>(635 const androidFileMenuActions = useMemo<MenuAction[]>(
611 () =>636 () =>
...@@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
766 truncated={fileData?.truncated ?? false}791 truncated={fileData?.truncated ?? false}
767 onRefresh={() => fileQuery.refresh()}792 onRefresh={() => fileQuery.refresh()}
768 />793 />
794 <FilePreviewModal
795 source={fullScreenPreview}
796 onRequestClose={() => setFullScreenPreview(null)}
797 />
769 </View>798 </View>
770 </ReviewHighlighterProvider>799 </ReviewHighlighterProvider>
771 );800 );
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+26-23
...@@ -1,24 +1,25 @@...@@ -1,24 +1,25 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState } from "react";2import { useId, useMemo, useState } from "react";
3import { ActivityIndicator, Image, Pressable, View } from "react-native";3import { ActivityIndicator, Image, Pressable, View } from "react-native";
4import ImageViewing from "react-native-image-viewing";
5import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
65
7import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
8import { EmptyState } from "../../components/EmptyState";7import { EmptyState } from "../../components/EmptyState";
9import { workspaceFileImageAtom } from "./workspace-file-image-cache";8import { workspaceFileImageAtom } from "./workspace-file-image-cache";
9import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
10import { PresentationSource } from "../../components/NativePresentation";
1011
11function ResolvedWorkspaceFileImagePreview(props: {12function ResolvedWorkspaceFileImagePreview(props: {
12 readonly accessibilityLabel: string;13 readonly accessibilityLabel: string;
13 readonly uri: string;14 readonly uri: string;
14}) {15}) {
15 const [loadError, setLoadError] = useState<string | null>(null);16 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();
17 const imageSource = useMemo(19 const imageSource = useMemo(
18 () => ({ uri: props.uri, cache: "force-cache" as const }),20 () => ({ uri: props.uri, cache: "force-cache" as const }),
19 [props.uri],21 [props.uri],
20 );22 );
21 const fullScreenImages = useMemo(() => [imageSource], [imageSource]);
2223
23 return (24 return (
24 <View className="relative flex-1 bg-subtle">25 <View className="relative flex-1 bg-subtle">
...@@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: {...@@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: {
27 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}28 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
28 disabled={loadError !== null}29 disabled={loadError !== null}
29 className="flex-1 p-4 active:bg-subtle-strong"30 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 }
31 >39 >
32 <Image40 <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
33 accessible={false}41 <Image
34 source={imageSource}42 accessible={false}
35 className="h-full w-full"43 source={imageSource}
36 resizeMode="contain"44 className="h-full w-full"
37 onLoadStart={() => setLoadError(null)}45 resizeMode="contain"
38 onError={(event) => {46 onLoadStart={() => setLoadError(null)}
39 setLoadError(event.nativeEvent.error || "The image could not be rendered.");47 onError={(event) => {
40 }}48 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
41 />49 }}
50 />
51 </PresentationSource>
42 </Pressable>52 </Pressable>
4353
44 {loadError !== null ? (54 {loadError !== null ? (
...@@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: {...@@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
47 </View>57 </View>
48 ) : null}58 ) : null}
4959
50 <ImageViewing60 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
51 images={fullScreenImages}
52 imageIndex={0}
53 visible={fullScreenVisible}
54 onRequestClose={() => setFullScreenVisible(false)}
55 swipeToCloseEnabled
56 doubleTapToZoomEnabled
57 />
58 </View>61 </View>
59 );62 );
60}63}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-11
...@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";...@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import ImageViewing from "react-native-image-viewing";8import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
99
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";11import { SymbolView } from "../../components/AppSymbol";
...@@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
53 Record<string, ReadonlyArray<ReviewHighlightedToken>>53 Record<string, ReadonlyArray<ReviewHighlightedToken>>
54 >({});54 >({});
55 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);55 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
56 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);56 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
5757
58 const selectedLines = useMemo(58 const selectedLines = useMemo(
59 () => (target ? getSelectedReviewCommentLines(target) : []),59 () => (target ? getSelectedReviewCommentLines(target) : []),
...@@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
272 attachments={attachments}272 attachments={attachments}
273 imageBorderRadius={16}273 imageBorderRadius={16}
274 imageSize={60}274 imageSize={60}
275 onPressImage={setPreviewImageUri}275 onPressPreview={setPreviewFile}
276 removeButtonPlacement="gutter"276 removeButtonPlacement="gutter"
277 onRemove={(imageId) => {277 onRemove={(imageId) => {
278 setAttachments((current) =>278 setAttachments((current) =>
...@@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
332 </View>332 </View>
333 </KeyboardStickyView>333 </KeyboardStickyView>
334 ) : null}334 ) : null}
335 <ImageViewing335 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
336 images={previewImageUri ? [{ uri: previewImageUri }] : []}
337 imageIndex={0}
338 visible={previewImageUri !== null}
339 onRequestClose={() => setPreviewImageUri(null)}
340 swipeToCloseEnabled
341 doubleTapToZoomEnabled
342 />
343 </View>336 </View>
344 );337 );
345}338}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21-5
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
36import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";36import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
37import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";37import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
38import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
38import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";39import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
39import { ProviderIcon } from "../../components/ProviderIcon";40import { ProviderIcon } from "../../components/ProviderIcon";
40import { SymbolView } from "../../components/AppSymbol";41import { SymbolView } from "../../components/AppSymbol";
...@@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: {...@@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: {
167 const loadedBranchesProjectKeyRef = useRef<string | null>(null);168 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
168 const [isComposerFocused, setIsComposerFocused] = useState(false);169 const [isComposerFocused, setIsComposerFocused] = useState(false);
169 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);170 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);
171 const openVideoPreview = useCallback(173 const openVideoPreview = useCallback(
172 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {174 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
173 wasFocusedBeforeVideoRef.current = isComposerFocused;175 wasFocusedBeforePreviewRef.current = isComposerFocused;
176 setPreviewFile(null);
174 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });177 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
175 },178 },
176 [isComposerFocused],179 [isComposerFocused],
177 );180 );
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(() => {
179 setPreviewVideo(null);190 setPreviewVideo(null);
180 if (wasFocusedBeforeVideoRef.current) {191 setPreviewFile(null);
192 if (wasFocusedBeforePreviewRef.current) {
181 setTimeout(() => {193 setTimeout(() => {
182 if (navigation.isFocused()) promptInputRef.current?.focus();194 if (navigation.isFocused()) promptInputRef.current?.focus();
183 }, 100);195 }, 100);
...@@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: {...@@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: {
1212 ? () => undefined1224 ? () => undefined
1213 : flow.removeAttachment1225 : flow.removeAttachment
1214 }1226 }
1227 onPressPreview={
1228 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
1229 }
1215 onPressVideo={1230 onPressVideo={
1216 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview1231 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
1217 }1232 }
...@@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: {...@@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: {
1318 </ComposerDictationToolbar>1333 </ComposerDictationToolbar>
1319 </Animated.View>1334 </Animated.View>
1320 </ComposerSurface>1335 </ComposerSurface>
1321 <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} />1336 <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} />
1337 <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} />
1322 </View>1338 </View>
1323 );1339 );
13241340
apps/mobile/src/features/threads/ThreadComposer.tsx+10-17
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20 type RefObject,20 type RefObject,
21} from "react";21} from "react";
22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
23import ImageViewing from "react-native-image-viewing";23import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
24import Animated, {24import Animated, {
25 FadeIn,25 FadeIn,
26 FadeInDown,26 FadeInDown,
...@@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
311 const inFlightThreadIdsRef = useRef(new Set<string>());311 const inFlightThreadIdsRef = useRef(new Set<string>());
312 const { onExpandedChange } = props;312 const { onExpandedChange } = props;
313313
314 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);314 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317 const showStopAction =317 const showStopAction =
...@@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
372 onExpandedChange?.(isExpanded);372 onExpandedChange?.(isExpanded);
373 }, [isExpanded, onExpandedChange]);373 }, [isExpanded, onExpandedChange]);
374374
375 const onPressImage = useCallback(375 const onPressPreview = useCallback(
376 (uri: string) => {376 (source: FilePreviewSource) => {
377 wasExpandedBeforePreviewRef.current = isFocused;377 wasExpandedBeforePreviewRef.current = isFocused;
378 setPreviewVideo(null);378 setPreviewVideo(null);
379 setPreviewImageUri(uri);379 setPreviewFile((current) => current ?? source);
380 },380 },
381 [isFocused],381 [isFocused],
382 );382 );
383383
384 const closePreview = useCallback(() => {384 const closePreview = useCallback(() => {
385 setPreviewImageUri(null);385 setPreviewFile(null);
386 setPreviewVideo(null);386 setPreviewVideo(null);
387 if (wasExpandedBeforePreviewRef.current) {387 if (wasExpandedBeforePreviewRef.current) {
388 setTimeout(() => {388 setTimeout(() => {
...@@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
394 const onPressVideo = useCallback(394 const onPressVideo = useCallback(
395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
396 wasExpandedBeforePreviewRef.current = isFocused;396 wasExpandedBeforePreviewRef.current = isFocused;
397 setPreviewImageUri(null);397 setPreviewFile(null);
398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
399 },399 },
400 [isFocused],400 [isFocused],
...@@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
619 <ComposerAttachmentStrip619 <ComposerAttachmentStrip
620 attachments={props.draftAttachments}620 attachments={props.draftAttachments}
621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622 onPressImage={voiceInput.isBusy ? undefined : onPressImage}622 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
624 />624 />
625 </Animated.View>625 </Animated.View>
...@@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
673 size={30}673 size={30}
674 borderRadius={8}674 borderRadius={8}
675 compact675 compact
676 onPressImage={onPressImage}676 onPressPreview={onPressPreview}
677 onPressVideo={onPressVideo}677 onPressVideo={onPressVideo}
678 />678 />
679 ))}679 ))}
...@@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
819 </Animated.View>819 </Animated.View>
820820
821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
822 <ImageViewing822 <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
823 images={previewImageUri ? [{ uri: previewImageUri }] : []}
824 imageIndex={0}
825 visible={previewImageUri !== null}
826 onRequestClose={closePreview}
827 swipeToCloseEnabled
828 doubleTapToZoomEnabled
829 />
830 </Animated.View>823 </Animated.View>
831 );824 );
832});825});
apps/mobile/src/features/threads/ThreadFeed.tsx+152-79
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 useMemo,36 useMemo,
37 useRef,37 useRef,
38 useState,38 useState,
39 useId,
39 type ReactNode,40 type ReactNode,
40 type RefObject,41 type RefObject,
41} from "react";42} from "react";
...@@ -62,8 +63,9 @@ import {...@@ -62,8 +63,9 @@ import {
62 View,63 View,
63 type ViewStyle,64 type ViewStyle,
64} from "react-native";65} from "react-native";
65import { TouchableOpacity } from "react-native-gesture-handler";66import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
66import ImageViewing from "react-native-image-viewing";67import { isPdfFile } from "../../lib/filePreview";
68import { PresentationSource } from "../../components/NativePresentation";
67import { useSafeAreaInsets } from "react-native-safe-area-context";69import { useSafeAreaInsets } from "react-native-safe-area-context";
68import Animated, {70import Animated, {
69 FadeIn,71 FadeIn,
...@@ -212,9 +214,11 @@ export interface ThreadFeedProps {...@@ -212,9 +214,11 @@ export interface ThreadFeedProps {
212function MessageAttachmentImage(props: {214function MessageAttachmentImage(props: {
213 readonly environmentId: EnvironmentId;215 readonly environmentId: EnvironmentId;
214 readonly attachmentId: string;216 readonly attachmentId: string;
217 readonly name: string;
215 readonly className: string;218 readonly className: string;
216 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;219 readonly onPressPreview: (source: FilePreviewSource) => void;
217}) {220}) {
221 const sourceIdentifier = useId();
218 const uri = useAssetUrl(props.environmentId, {222 const uri = useAssetUrl(props.environmentId, {
219 _tag: "attachment",223 _tag: "attachment",
220 attachmentId: props.attachmentId,224 attachmentId: props.attachmentId,
...@@ -229,9 +233,17 @@ function MessageAttachmentImage(props: {...@@ -229,9 +233,17 @@ function MessageAttachmentImage(props: {
229 }233 }
230234
231 return (235 return (
232 <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>236 <PresentationSource identifier={sourceIdentifier}>
233 <Image source={{ uri }} className={props.className} resizeMode="cover" />237 <Pressable
234 </TouchableOpacity>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>
235 );247 );
236}248}
237249
...@@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt...@@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt
249function MessageAttachmentFile(props: {261function MessageAttachmentFile(props: {
250 readonly environmentId: EnvironmentId;262 readonly environmentId: EnvironmentId;
251 readonly attachment: ChatFileAttachment;263 readonly attachment: ChatFileAttachment;
264 readonly onPressPreview: (source: FilePreviewSource) => void;
252 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;265 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
253}) {266}) {
267 const sourceIdentifier = useId();
254 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {268 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
255 reportFailure: false,269 reportFailure: false,
256 });270 });
257 const preparedConnection = usePreparedConnection(props.environmentId);271 const preparedConnection = usePreparedConnection(props.environmentId);
258 const { attachment } = props;272 const { attachment } = props;
259 const videoType = videoMimeType(attachment);273 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);
260 const thumbnailUrl = useAssetUrl(279 const thumbnailUrl = useAssetUrl(
261 props.environmentId,280 props.environmentId,
262 videoType === null281 videoType === null
...@@ -348,26 +367,63 @@ function MessageAttachmentFile(props: {...@@ -348,26 +367,63 @@ function MessageAttachmentFile(props: {
348 }367 }
349368
350 return (369 return (
351 <Pressable370 <PresentationSource
352 accessibilityRole="button"371 identifier={sourceIdentifier}
353 accessibilityLabel={`Open ${attachment.name}`}372 className="my-1"
354 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}373 style={{ width: 280, maxWidth: "100%" }}
355 disabled={opening || httpBaseUrl === null}
356 className="flex-row items-center gap-2 py-1"
357 onPress={() => shareFile()}
358 >374 >
359 {opening ? (375 <Pressable
360 <ActivityIndicator size="small" />376 accessibilityRole="button"
361 ) : (377 accessibilityLabel={`Open ${attachment.name}`}
362 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />378 accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}
363 )}379 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
364 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>380 disabled={opening || httpBaseUrl === null}
365 {attachment.name}381 className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"
366 </Text>382 onPress={() =>
367 <Text className="text-xs text-foreground-muted">383 isPdf
368 {formatAttachmentSize(attachment.sizeBytes)}384 ? props.onPressPreview({
369 </Text>385 kind: "pdf",
370 </Pressable>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>
371 );427 );
372}428}
373429
...@@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: {...@@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: {
391 readonly sourceKey: string;447 readonly sourceKey: string;
392 readonly unavailable: boolean;448 readonly unavailable: boolean;
393 readonly alt: string | null;449 readonly alt: string | null;
394 readonly onPressImage: (uri: string) => void;450 readonly onPressPreview: (source: FilePreviewSource) => void;
395}) {451}) {
452 const sourceIdentifier = useId();
396 const [availableWidth, setAvailableWidth] = useState(0);453 const [availableWidth, setAvailableWidth] = useState(0);
397 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);454 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
398 const [failedUri, setFailedUri] = useState<string | null>(null);455 const [failedUri, setFailedUri] = useState<string | null>(null);
...@@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: {...@@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: {
437 )}494 )}
438 </View>495 </View>
439 ) : (496 ) : (
440 <TouchableOpacity497 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
441 accessibilityRole="imagebutton"498 <Pressable
442 accessibilityLabel={props.alt ?? "Markdown image"}499 accessibilityRole="imagebutton"
443 activeOpacity={0.7}500 accessibilityLabel={props.alt ?? "Markdown image"}
444 onPress={() => props.onPressImage(props.uri!)}501 onPress={() =>
445 style={{ alignSelf: "flex-start" }}502 props.onPressPreview({
446 >503 kind: "image",
447 <View504 uri: props.uri!,
448 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"505 name: props.alt ?? "Image",
449 style={{506 sourceIdentifier,
450 ...frameStyle,507 })
451 }}508 }
509 style={{ alignSelf: "flex-start" }}
452 >510 >
453 <ThreadMarkdownImageRequest511 <View
454 key={props.uri}512 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
455 uri={props.uri}513 style={{
456 onLoad={setSourceSize}514 ...frameStyle,
457 onError={() => setFailedUri(props.uri)}515 }}
458 />516 >
459 </View>517 <ThreadMarkdownImageRequest
460 </TouchableOpacity>518 key={props.uri}
519 uri={props.uri}
520 onLoad={setSourceSize}
521 onError={() => setFailedUri(props.uri)}
522 />
523 </View>
524 </Pressable>
525 </PresentationSource>
461 )}526 )}
462 {props.alt ? (527 {props.alt ? (
463 <Text selectable className="text-xs text-foreground-muted">528 <Text selectable className="text-xs text-foreground-muted">
...@@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: {...@@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: {
506 readonly threadId: ThreadId;571 readonly threadId: ThreadId;
507 readonly path: string;572 readonly path: string;
508 readonly alt: string | null;573 readonly alt: string | null;
509 readonly onPressImage: (uri: string) => void;574 readonly onPressPreview: (source: FilePreviewSource) => void;
510}) {575}) {
511 const assetUrl = useAssetUrlState(props.environmentId, {576 const assetUrl = useAssetUrlState(props.environmentId, {
512 _tag: "workspace-file",577 _tag: "workspace-file",
...@@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: {...@@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: {
520 sourceKey={props.path}585 sourceKey={props.path}
521 unavailable={assetUrl._tag === "Failure"}586 unavailable={assetUrl._tag === "Failure"}
522 alt={props.alt}587 alt={props.alt}
523 onPressImage={props.onPressImage}588 onPressPreview={props.onPressPreview}
524 />589 />
525 );590 );
526}591}
...@@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null })...@@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null })
532 sourceKey="unavailable"597 sourceKey="unavailable"
533 unavailable598 unavailable
534 alt={props.alt}599 alt={props.alt}
535 onPressImage={() => undefined}600 onPressPreview={() => undefined}
536 />601 />
537 );602 );
538}603}
...@@ -1264,7 +1329,7 @@ function renderFeedEntry(...@@ -1264,7 +1329,7 @@ function renderFeedEntry(
1264 readonly onToggleWorkGroup: (groupId: string) => void;1329 readonly onToggleWorkGroup: (groupId: string) => void;
1265 readonly onToggleWorkRow: (rowId: string) => void;1330 readonly onToggleWorkRow: (rowId: string) => void;
1266 readonly onToggleTurnFold: (turnId: TurnId) => void;1331 readonly onToggleTurnFold: (turnId: TurnId) => void;
1267 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;1332 readonly onPressPreview: (source: FilePreviewSource) => void;
1268 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1333 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1269 readonly onMarkdownLinkPress: (href: string) => void;1334 readonly onMarkdownLinkPress: (href: string) => void;
1270 readonly renderMarkdownImage: MarkdownImageRenderer;1335 readonly renderMarkdownImage: MarkdownImageRenderer;
...@@ -1375,14 +1440,16 @@ function renderFeedEntry(...@@ -1375,14 +1440,16 @@ function renderFeedEntry(
1375 key={attachment.id}1440 key={attachment.id}
1376 environmentId={props.environmentId}1441 environmentId={props.environmentId}
1377 attachmentId={attachment.id}1442 attachmentId={attachment.id}
1443 name={attachment.name}
1378 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"1444 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
1379 onPressImage={props.onPressImage}1445 onPressPreview={props.onPressPreview}
1380 />1446 />
1381 ) : isFileAttachment(attachment) ? (1447 ) : isFileAttachment(attachment) ? (
1382 <MessageAttachmentFile1448 <MessageAttachmentFile
1383 key={attachment.id}1449 key={attachment.id}
1384 environmentId={props.environmentId}1450 environmentId={props.environmentId}
1385 attachment={attachment}1451 attachment={attachment}
1452 onPressPreview={props.onPressPreview}
1386 onPressVideo={props.onPressVideo}1453 onPressVideo={props.onPressVideo}
1387 />1454 />
1388 ) : (1455 ) : (
...@@ -1436,14 +1503,16 @@ function renderFeedEntry(...@@ -1436,14 +1503,16 @@ function renderFeedEntry(
1436 key={attachment.id}1503 key={attachment.id}
1437 environmentId={props.environmentId}1504 environmentId={props.environmentId}
1438 attachmentId={attachment.id}1505 attachmentId={attachment.id}
1506 name={attachment.name}
1439 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"1507 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
1440 onPressImage={props.onPressImage}1508 onPressPreview={props.onPressPreview}
1441 />1509 />
1442 ) : isFileAttachment(attachment) ? (1510 ) : isFileAttachment(attachment) ? (
1443 <MessageAttachmentFile1511 <MessageAttachmentFile
1444 key={attachment.id}1512 key={attachment.id}
1445 environmentId={props.environmentId}1513 environmentId={props.environmentId}
1446 attachment={attachment}1514 attachment={attachment}
1515 onPressPreview={props.onPressPreview}
1447 onPressVideo={props.onPressVideo}1516 onPressVideo={props.onPressVideo}
1448 />1517 />
1449 ) : (1518 ) : (
...@@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1806 expandedTurnIds: new Set(),1875 expandedTurnIds: new Set(),
1807 });1876 });
1808 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;1877 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
1809 const [expandedImage, setExpandedImage] = useState<{1878 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
1810 uri: string;
1811 headers?: Record<string, string>;
1812 } | null>(null);
1813 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);1879 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1814 useEffect(() => {1880 useEffect(() => {
1815 setExpandedVideo(null);1881 setExpandedVideo(null);
1882 setExpandedFile(null);
1816 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);1883 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
1817 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;1884 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1818 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({1885 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
...@@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1858 );1925 );
1859 if (relativePath) {1926 if (relativePath) {
1860 void Haptics.selectionAsync();1927 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 }
1861 navigation.navigate("ThreadFile", {1944 navigation.navigate("ThreadFile", {
1862 environmentId: String(props.environmentId),1945 environmentId: String(props.environmentId),
1863 threadId: String(props.threadId),1946 threadId: String(props.threadId),
...@@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1869 }1952 }
18701953
1871 if (presentation.href) {1954 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 }
1872 void tryOpenExternalUrl(presentation.href, "markdown-link");1961 void tryOpenExternalUrl(presentation.href, "markdown-link");
1873 }1962 }
1874 },1963 },
...@@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1884 sourceKey={imageSource.uri}1973 sourceKey={imageSource.uri}
1885 unavailable={false}1974 unavailable={false}
1886 alt={image.alt}1975 alt={image.alt}
1887 onPressImage={(uri) => setExpandedImage({ uri })}1976 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1888 />1977 />
1889 );1978 );
1890 }1979 }
...@@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1897 threadId={props.threadId}1986 threadId={props.threadId}
1898 path={imageSource.path}1987 path={imageSource.path}
1899 alt={image.alt}1988 alt={image.alt}
1900 onPressImage={(uri) => setExpandedImage({ uri })}1989 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1901 />1990 />
1902 );1991 );
1903 },1992 },
...@@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2285 [suspendEndScrollMaintenanceForDisclosure],2374 [suspendEndScrollMaintenanceForDisclosure],
2286 );2375 );
22872376
2288 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {2377 const onPressPreview = useCallback((source: FilePreviewSource) => {
2289 setExpandedImage({ uri, headers });2378 setExpandedFile((current) => current ?? source);
2290 }, []);2379 }, []);
2291 const onPressVideo = useCallback(2380 const onPressVideo = useCallback(
2292 (attachment: ChatFileAttachment, sourceIdentifier: string) => {2381 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
...@@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2350 onToggleWorkGroup,2439 onToggleWorkGroup,
2351 onToggleWorkRow,2440 onToggleWorkRow,
2352 onToggleTurnFold,2441 onToggleTurnFold,
2353 onPressImage,2442 onPressPreview,
2354 onPressVideo,2443 onPressVideo,
2355 onMarkdownLinkPress,2444 onMarkdownLinkPress,
2356 renderMarkdownImage,2445 renderMarkdownImage,
...@@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2379 userBubbleMaxWidth,2468 userBubbleMaxWidth,
2380 onCopyWorkRow,2469 onCopyWorkRow,
2381 onMarkdownLinkPress,2470 onMarkdownLinkPress,
2382 onPressImage,2471 onPressPreview,
2383 onPressVideo,2472 onPressVideo,
2384 onToggleTurnFold,2473 onToggleTurnFold,
2385 onToggleWorkGroup,2474 onToggleWorkGroup,
...@@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2565 </View>2654 </View>
25662655
2567 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />2656 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2568 <ImageViewing2657 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
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 />
2585 </>2658 </>
2586 );2659 );
2587});2660});
apps/mobile/src/lib/composerFiles.test.ts+112
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
2import type { ImagePickerAsset } from "expo-image-picker";3import type { ImagePickerAsset } from "expo-image-picker";
34
4const mocks = vi.hoisted(() => ({5const mocks = vi.hoisted(() => ({
...@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({...@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
9 delete: vi.fn(),10 delete: vi.fn(),
10 open: vi.fn(),11 open: vi.fn(),
11 size: vi.fn(),12 size: vi.fn(),
13 readBase64: vi.fn(),
12}));14}));
1315
14vi.mock("expo-file-system", () => {16vi.mock("expo-file-system", () => {
...@@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => {...@@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => {
55 mocks.copy(this.uri, destination.uri);57 mocks.copy(this.uri, destination.uri);
56 }58 }
5759
60 async base64(): Promise<string> {
61 return mocks.readBase64(this.uri);
62 }
63
58 delete(): void {64 delete(): void {
59 mocks.delete(this.uri);65 mocks.delete(this.uri);
60 }66 }
...@@ -95,9 +101,115 @@ describe("composer file attachments", () => {...@@ -95,9 +101,115 @@ describe("composer file attachments", () => {
95 mocks.delete.mockReset();101 mocks.delete.mockReset();
96 mocks.open.mockReset();102 mocks.open.mockReset();
97 mocks.size.mockReset();103 mocks.size.mockReset();
104 mocks.readBase64.mockReset();
98 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));105 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
99 });106 });
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
101 describe("photo library videos", () => {213 describe("photo library videos", () => {
102 const image: ImagePickerAsset = {214 const image: ImagePickerAsset = {
103 uri: "file:///picker/photo.png",215 uri: "file:///picker/photo.png",
apps/mobile/src/lib/composerImages.ts+37-11
...@@ -351,7 +351,7 @@ export async function pickComposerMedia(input: {...@@ -351,7 +351,7 @@ export async function pickComposerMedia(input: {
351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
352 break;352 break;
353 }353 }
354 const mimeType = asset.mimeType?.toLowerCase();354 let mimeType = asset.mimeType?.toLowerCase();
355 if (asset.type === "video" || mimeType?.startsWith("video/")) {355 if (asset.type === "video" || mimeType?.startsWith("video/")) {
356 if (input.maxVideoBytes === undefined) {356 if (input.maxVideoBytes === undefined) {
357 error = "Video attachments are unavailable here.";357 error = "Video attachments are unavailable here.";
...@@ -375,35 +375,61 @@ export async function pickComposerMedia(input: {...@@ -375,35 +375,61 @@ export async function pickComposerMedia(input: {
375 }375 }
376 continue;376 continue;
377 }377 }
378 if (!mimeType?.startsWith("image/")) {378 if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
380 continue;380 continue;
381 }381 }
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;
388 if (!base64) {384 if (!base64) {
389 error = `Failed to read '${asset.fileName ?? "image"}'.`;385 error = `Failed to read '${asset.fileName ?? "image"}'.`;
390 continue;386 continue;
391 }387 }
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);
394 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {419 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
395 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;420 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
396 continue;421 continue;
397 }422 }
398423
424 const dataUrl = `data:${mimeType};base64,${base64}`;
399 attachments.push({425 attachments.push({
400 id: uuidv4(),426 id: uuidv4(),
401 type: "image",427 type: "image",
402 name: asset.fileName ?? "image",428 name,
403 mimeType,429 mimeType,
404 sizeBytes,430 sizeBytes,
405 dataUrl: `data:${mimeType};base64,${base64}`,431 dataUrl,
406 previewUri: asset.uri,432 previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl,
407 });433 });
408 }434 }
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....@@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround.
5151
52## Native media presentations52## Native media presentations
5353
54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit,
55full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child55image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child
56and pass the stable identifier to the presentation. The registry keeps weak56and pass the stable identifier to the presentation. The registry keeps weak
57references to source views; recycled or compact composer thumbnails can register57references to source views; recycled or compact composer thumbnails can register
58the same identifier. Identifiers must distinguish simultaneously visible attachments.58the same identifier. Identifiers must distinguish simultaneously visible attachments.
59This source registration does not own playback and can be reused by a future image59The source registration does not own the preview. Android uses a regular view.
60viewer. Android uses a regular view.
6160
62On iOS, video previews mount `AVPlayerViewController` temporarily inside the61On iOS, video previews mount `AVPlayerViewController` temporarily inside the
63registered source and enter full screen through AVKit. AVKit62registered 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...@@ -67,7 +66,25 @@ simulator, that leaves native Close unable to exit full screen. When the source
67the player uses a standard modal presentation. Programmatic entry uses the same66the player uses a standard modal presentation. Programmatic entry uses the same
68guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;67guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
69if that selector is unavailable, the player also falls back to a standard modal.68if 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
72Received videos open directly from their signed asset URL. AVKit handles buffering;89Received videos open directly from their signed asset URL. AVKit handles buffering;
73the client does not download the entire file or show a separate opening overlay before90the 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 **...@@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and **
20Videos use the server's file upload limit. You can also share photos, videos, and files into20Videos use the server's file upload limit. You can also share photos, videos, and files into
21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
22sends, so queued messages keep their files until they deliver. Select a received file on mobile22sends, 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
25Select a video attachment before or after sending to play it. Web and desktop use the browser's31Select a video attachment before or after sending to play it. Web and desktop use the browser's
26built-in controls. On mobile, videos open in a full-screen player with native playback controls.32built-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...@@ -36,7 +42,8 @@ On web and desktop, if you reload before a file finishes uploading, the draft ke
36and shows **Attach again** next to it. Attach the file again or remove it, then send.42and shows **Attach again** next to it. Attach the file again or remove it, then send.
3743
38On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into44On 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
41On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,48On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
42GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list49GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list