| author | |
| committer | |
| log | f8e4accf27415eb9f7f4106087d2a1b0186d66b9 |
| tree | 92640b02ed6da9e78071cc50e0e1b4a75968a84e |
| parent | 6d15c5bbc3f3c331a7466bb94c17413a35f7b659 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 206 | }, |
| 207 | 207 | NSLocalNetworkUsageDescription: |
| 208 | 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 | 210 | ITSAppUsesNonExemptEncryption: false, |
| 210 | 211 | // The App Store screenshot harness rotates the iPad interface from |
| 211 | 212 | // 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 | 5 | public final class T3NativeControlsModule: Module { |
| 6 | 6 | private let presentationSources = T3PresentationSources() |
| 7 | 7 | private var videoPresentation: T3NativeVideoPresentation? |
| 8 | private var filePresentation: T3NativeFilePresentation? | |
| 8 | 9 | |
| 9 | 10 | public func definition() -> ModuleDefinition { |
| 10 | 11 | Name("T3NativeControls") |
| ... | ... | @@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { |
| 23 | 24 | self.dismissVideo(identifier: identifier) |
| 24 | 25 | }.runOnQueue(.main) |
| 25 | 26 | |
| 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 | 36 | OnDestroy { |
| 27 | 37 | 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 | } |
| 30 | 44 | |
| 31 | 45 | View(T3PresentationSourceView.self) { |
| ... | ... | @@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { |
| 140 | 154 | let isPlayableURL = url.isFileURL |
| 141 | 155 | ? FileManager.default.isReadableFile(atPath: url.path) |
| 142 | 156 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) |
| 143 | guard videoPresentation == nil, | |
| 157 | guard videoPresentation == nil, filePresentation == nil, | |
| 144 | 158 | let presenter = appContext?.utilities?.currentViewController(), |
| 145 | 159 | isPlayableURL |
| 146 | 160 | else { |
| ... | ... | @@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { |
| 162 | 176 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } |
| 163 | 177 | } |
| 164 | 178 | |
| 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 | 197 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { |
| 166 | 198 | guard let presenter = appContext?.utilities?.currentViewController() else { |
| 167 | 199 | throw NSError( |
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift created+165| ... | ... | @@ -0,0 +1,165 @@ |
| 1 | import ImageIO | |
| 2 | import QuickLook | |
| 3 | import UIKit | |
| 4 | import UniformTypeIdentifiers | |
| 5 | ||
| 6 | private final class FilePreviewItem: NSObject, QLPreviewItem { | |
| 7 | var previewItemURL: URL? | |
| 8 | var previewItemTitle: String? | |
| 9 | } | |
| 10 | ||
| 11 | private 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. | |
| 21 | final 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 | 6 | import { AppText as Text } from "./AppText"; |
| 7 | 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 9 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 10 | import { PresentationSource } from "./NativePresentation"; | |
| 11 | import type { FilePreviewSource } from "./FilePreviewModal"; | |
| 12 | import { isPdfFile } from "../lib/filePreview"; | |
| 10 | 13 | |
| 11 | 14 | export interface ComposerAttachmentStripProps { |
| 12 | 15 | /** Attachments to display. */ |
| 13 | 16 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 14 | 17 | /** Called when the user removes an attachment. */ |
| 15 | 18 | readonly onRemove: (imageId: string) => void; |
| 16 | /** Called when the user taps on an image thumbnail to preview it. */ | |
| 17 | readonly onPressImage?: (previewUri: string) => void; | |
| 19 | /** Called when the user taps an image or PDF to preview it. */ | |
| 20 | readonly onPressPreview?: (source: FilePreviewSource) => void; | |
| 18 | 21 | readonly onPressVideo?: ( |
| 19 | 22 | attachment: DraftComposerFileAttachment, |
| 20 | 23 | sourceIdentifier: string, |
| ... | ... | @@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { |
| 32 | 35 | readonly size: number; |
| 33 | 36 | readonly borderRadius: number; |
| 34 | 37 | readonly compact?: boolean; |
| 35 | readonly onPressImage?: (previewUri: string) => void; | |
| 38 | readonly onPressPreview?: (source: FilePreviewSource) => void; | |
| 36 | 39 | readonly onPressVideo?: ( |
| 37 | 40 | attachment: DraftComposerFileAttachment, |
| 38 | 41 | sourceIdentifier: string, |
| ... | ... | @@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { |
| 41 | 44 | const { attachment } = props; |
| 42 | 45 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 43 | 46 | if (attachment.type === "image") { |
| 47 | const sourceIdentifier = `draft-image:${attachment.id}`; | |
| 44 | 48 | return ( |
| 45 | <Pressable | |
| 46 | onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined} | |
| 47 | > | |
| 48 | <Image | |
| 49 | source={{ uri: attachment.previewUri }} | |
| 50 | style={style} | |
| 51 | className="bg-subtle" | |
| 52 | resizeMode="cover" | |
| 53 | /> | |
| 54 | </Pressable> | |
| 49 | <PresentationSource identifier={sourceIdentifier}> | |
| 50 | <Pressable | |
| 51 | accessibilityRole="imagebutton" | |
| 52 | accessibilityLabel={`Open ${attachment.name}`} | |
| 53 | disabled={!props.onPressPreview} | |
| 54 | onPress={() => | |
| 55 | props.onPressPreview?.({ | |
| 56 | kind: "image", | |
| 57 | uri: attachment.dataUrl, | |
| 58 | name: attachment.name, | |
| 59 | sourceIdentifier, | |
| 60 | }) | |
| 61 | } | |
| 62 | > | |
| 63 | <Image | |
| 64 | source={{ uri: attachment.previewUri }} | |
| 65 | style={style} | |
| 66 | className="bg-subtle" | |
| 67 | resizeMode="cover" | |
| 68 | /> | |
| 69 | </Pressable> | |
| 70 | </PresentationSource> | |
| 55 | 71 | ); |
| 56 | 72 | } |
| 57 | 73 | const onPressVideo = props.onPressVideo; |
| ... | ... | @@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { |
| 60 | 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 | 81 | return ( |
| 64 | <View | |
| 65 | className={ | |
| 66 | props.compact | |
| 67 | ? "items-center justify-center bg-subtle" | |
| 68 | : "items-center justify-center gap-1 bg-subtle px-2" | |
| 69 | } | |
| 70 | style={style} | |
| 71 | > | |
| 72 | <SymbolView | |
| 73 | name="doc.text" | |
| 74 | size={props.compact ? 15 : 22} | |
| 75 | tintColor="#a3a3a3" | |
| 76 | type="monochrome" | |
| 77 | /> | |
| 78 | {!props.compact ? ( | |
| 79 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 80 | {attachment.name} | |
| 81 | </Text> | |
| 82 | ) : null} | |
| 83 | </View> | |
| 82 | <PresentationSource identifier={sourceIdentifier}> | |
| 83 | <Pressable | |
| 84 | accessibilityRole="button" | |
| 85 | accessibilityLabel={`Open ${attachment.name}`} | |
| 86 | disabled={!canPreview} | |
| 87 | onPress={() => | |
| 88 | props.onPressPreview?.({ | |
| 89 | kind: "pdf", | |
| 90 | name: attachment.name, | |
| 91 | attachment, | |
| 92 | sourceIdentifier, | |
| 93 | }) | |
| 94 | } | |
| 95 | className={ | |
| 96 | props.compact | |
| 97 | ? "items-center justify-center bg-subtle" | |
| 98 | : "items-center justify-center gap-1 bg-subtle px-2" | |
| 99 | } | |
| 100 | style={style} | |
| 101 | > | |
| 102 | <SymbolView | |
| 103 | name="doc.text" | |
| 104 | size={props.compact ? 15 : 22} | |
| 105 | tintColor="#a3a3a3" | |
| 106 | type="monochrome" | |
| 107 | /> | |
| 108 | {!props.compact ? ( | |
| 109 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 110 | {attachment.name} | |
| 111 | </Text> | |
| 112 | ) : null} | |
| 113 | </Pressable> | |
| 114 | </PresentationSource> | |
| 84 | 115 | ); |
| 85 | 116 | } |
| 86 | 117 | |
| ... | ... | @@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { |
| 113 | 144 | shareRef.current = controller; |
| 114 | 145 | setSharing(true); |
| 115 | 146 | void (async () => { |
| 116 | const preview = await loadLocalVideoPreview(attachment, controller.signal); | |
| 147 | const preview = await loadLocalAttachmentPreview(attachment, controller.signal); | |
| 117 | 148 | if (!preview) return; |
| 118 | 149 | try { |
| 119 | 150 | await preview.share(controller.signal, sourceIdentifier); |
| ... | ... | @@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 185 | 216 | attachment={attachment} |
| 186 | 217 | size={size} |
| 187 | 218 | borderRadius={radius} |
| 188 | onPressImage={props.onPressImage} | |
| 219 | onPressPreview={props.onPressPreview} | |
| 189 | 220 | onPressVideo={props.onPressVideo} |
| 190 | 221 | /> |
| 191 | 222 | <Pressable |
apps/mobile/src/components/FilePreview.ios.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { requireNativeModule } from "expo"; | |
| 2 | import { useEffect, useEffectEvent, useId } from "react"; | |
| 3 | import { Alert } from "react-native"; | |
| 4 | ||
| 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | |
| 6 | ||
| 7 | const 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 | ||
| 17 | export 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 @@ |
| 1 | import { useEffect, useEffectEvent } from "react"; | |
| 2 | import { Alert } from "react-native"; | |
| 3 | import ImageViewing from "react-native-image-viewing"; | |
| 4 | ||
| 5 | import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; | |
| 6 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | |
| 7 | ||
| 8 | function 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 | ||
| 37 | export 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 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | |
| 2 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { useEffect, useEffectEvent, useState } from "react"; | |
| 4 | import { Alert, Keyboard } from "react-native"; | |
| 5 | ||
| 6 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 8 | import { useAssetUrlState } from "../state/assets"; | |
| 9 | import { usePreparedConnection } from "../state/session"; | |
| 10 | import { FilePreview } from "./FilePreview"; | |
| 11 | ||
| 12 | export interface ResolvedFilePreviewSource { | |
| 13 | readonly kind: "image" | "pdf"; | |
| 14 | readonly uri: string; | |
| 15 | readonly name?: string; | |
| 16 | readonly sourceIdentifier?: string; | |
| 17 | } | |
| 18 | ||
| 19 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & | |
| 20 | ( | |
| 21 | | { readonly uri: string } | |
| 22 | | { readonly attachment: DraftComposerFileAttachment } | |
| 23 | | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } | |
| 24 | ); | |
| 25 | ||
| 26 | function 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 | ||
| 80 | export 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 | 4 | import { useEffect, useEffectEvent, useId, useState } from "react"; |
| 5 | 5 | import { Alert, Keyboard } from "react-native"; |
| 6 | 6 | |
| 7 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 8 | 8 | import { useAssetUrlState } from "../state/assets"; |
| 9 | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; |
| ... | ... | @@ -67,7 +67,7 @@ function NativeVideoPreview(props: { |
| 67 | 67 | void (async () => { |
| 68 | 68 | const file = |
| 69 | 69 | source.type === "local" |
| 70 | ? await loadLocalVideoPreview(source.attachment, controller.signal) | |
| 70 | ? await loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 71 | 71 | : null; |
| 72 | 72 | if (source.type === "local" && !file) return; |
| 73 | 73 | try { |
apps/mobile/src/components/VideoPreviewModal.tsx+2-2| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | type AttachmentPreviewFile, |
| 21 | 21 | } from "../lib/attachmentDownload"; |
| 22 | 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 23 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 23 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 24 | 24 | import { useAssetUrlState } from "../state/assets"; |
| 25 | 25 | import { usePreparedConnection } from "../state/session"; |
| 26 | 26 | import { SymbolView } from "./AppSymbol"; |
| ... | ... | @@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { |
| 155 | 155 | setFailure(null); |
| 156 | 156 | const loading = |
| 157 | 157 | source.type === "local" |
| 158 | ? loadLocalVideoPreview(source.attachment, controller.signal) | |
| 158 | ? loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 159 | 159 | : downloadAttachmentForPreview({ |
| 160 | 160 | url: downloadUrl!, |
| 161 | 161 | attachment: { name: attachment.name, mimeType }, |
apps/mobile/src/components/VideoThumbnailImage.tsx+2-2| ... | ... | @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; |
| 5 | 5 | import { StyleSheet } from "react-native"; |
| 6 | 6 | |
| 7 | 7 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 8 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 9 | 9 | import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; |
| 10 | 10 | |
| 11 | 11 | export function VideoThumbnailImage(props: { |
| ... | ... | @@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { |
| 25 | 25 | async (signal) => |
| 26 | 26 | typeof source === "string" |
| 27 | 27 | ? { uri: source, dispose: () => undefined } |
| 28 | : loadLocalVideoPreview(source, signal), | |
| 28 | : loadLocalAttachmentPreview(source, signal), | |
| 29 | 29 | controller.signal, |
| 30 | 30 | ).then((thumbnail) => { |
| 31 | 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 | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 18 | 18 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 19 | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 20 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 20 | 21 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 21 | 22 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| 22 | 23 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 24 | import { isPdfFile } from "../../lib/filePreview"; | |
| 23 | 25 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 24 | 26 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 25 | 27 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| ... | ... | @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 487 | 489 | readonly mode: FileViewMode; |
| 488 | 490 | } | null>(null); |
| 489 | 491 | const [previewRevision, setPreviewRevision] = useState(0); |
| 492 | const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null); | |
| 490 | 493 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); |
| 491 | 494 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); |
| 492 | 495 | const canPreview = |
| ... | ... | @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 586 | 589 | inline: false, |
| 587 | 590 | onPress: () => copyTextWithHaptic(relativePath), |
| 588 | 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 | 606 | isBrowserFile && typeof assetPreviewUri === "string" |
| 590 | 607 | ? ({ |
| 591 | 608 | id: "open-browser", |
| ... | ... | @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 605 | 622 | } as const) |
| 606 | 623 | : null, |
| 607 | 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 | ]); | |
| 609 | 634 | |
| 610 | 635 | const androidFileMenuActions = useMemo<MenuAction[]>( |
| 611 | 636 | () => |
| ... | ... | @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 766 | 791 | truncated={fileData?.truncated ?? false} |
| 767 | 792 | onRefresh={() => fileQuery.refresh()} |
| 768 | 793 | /> |
| 794 | <FilePreviewModal | |
| 795 | source={fullScreenPreview} | |
| 796 | onRequestClose={() => setFullScreenPreview(null)} | |
| 797 | /> | |
| 769 | 798 | </View> |
| 770 | 799 | </ReviewHighlighterProvider> |
| 771 | 800 | ); |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+26-23| ... | ... | @@ -1,24 +1,25 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useMemo, useState } from "react"; | |
| 2 | import { useId, useMemo, useState } from "react"; | |
| 3 | 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; |
| 4 | import ImageViewing from "react-native-image-viewing"; | |
| 5 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 6 | 5 | |
| 7 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 7 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | 8 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; |
| 9 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 10 | import { PresentationSource } from "../../components/NativePresentation"; | |
| 10 | 11 | |
| 11 | 12 | function ResolvedWorkspaceFileImagePreview(props: { |
| 12 | 13 | readonly accessibilityLabel: string; |
| 13 | 14 | readonly uri: string; |
| 14 | 15 | }) { |
| 15 | 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 | 19 | const imageSource = useMemo( |
| 18 | 20 | () => ({ uri: props.uri, cache: "force-cache" as const }), |
| 19 | 21 | [props.uri], |
| 20 | 22 | ); |
| 21 | const fullScreenImages = useMemo(() => [imageSource], [imageSource]); | |
| 22 | 23 | |
| 23 | 24 | return ( |
| 24 | 25 | <View className="relative flex-1 bg-subtle"> |
| ... | ... | @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 27 | 28 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} |
| 28 | 29 | disabled={loadError !== null} |
| 29 | 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 | <Image | |
| 33 | accessible={false} | |
| 34 | source={imageSource} | |
| 35 | className="h-full w-full" | |
| 36 | resizeMode="contain" | |
| 37 | onLoadStart={() => setLoadError(null)} | |
| 38 | onError={(event) => { | |
| 39 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 40 | }} | |
| 41 | /> | |
| 40 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> | |
| 41 | <Image | |
| 42 | accessible={false} | |
| 43 | source={imageSource} | |
| 44 | className="h-full w-full" | |
| 45 | resizeMode="contain" | |
| 46 | onLoadStart={() => setLoadError(null)} | |
| 47 | onError={(event) => { | |
| 48 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 49 | }} | |
| 50 | /> | |
| 51 | </PresentationSource> | |
| 42 | 52 | </Pressable> |
| 43 | 53 | |
| 44 | 54 | {loadError !== null ? ( |
| ... | ... | @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 47 | 57 | </View> |
| 48 | 58 | ) : null} |
| 49 | 59 | |
| 50 | <ImageViewing | |
| 51 | images={fullScreenImages} | |
| 52 | imageIndex={0} | |
| 53 | visible={fullScreenVisible} | |
| 54 | onRequestClose={() => setFullScreenVisible(false)} | |
| 55 | swipeToCloseEnabled | |
| 56 | doubleTapToZoomEnabled | |
| 57 | /> | |
| 60 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | |
| 58 | 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 | 5 | import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; |
| 6 | 6 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import ImageViewing from "react-native-image-viewing"; | |
| 8 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 9 | 9 | |
| 10 | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 11 | 11 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 53 | 53 | Record<string, ReadonlyArray<ReviewHighlightedToken>> |
| 54 | 54 | >({}); |
| 55 | 55 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); |
| 56 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | |
| 56 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | |
| 57 | 57 | |
| 58 | 58 | const selectedLines = useMemo( |
| 59 | 59 | () => (target ? getSelectedReviewCommentLines(target) : []), |
| ... | ... | @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 272 | 272 | attachments={attachments} |
| 273 | 273 | imageBorderRadius={16} |
| 274 | 274 | imageSize={60} |
| 275 | onPressImage={setPreviewImageUri} | |
| 275 | onPressPreview={setPreviewFile} | |
| 276 | 276 | removeButtonPlacement="gutter" |
| 277 | 277 | onRemove={(imageId) => { |
| 278 | 278 | setAttachments((current) => |
| ... | ... | @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 332 | 332 | </View> |
| 333 | 333 | </KeyboardStickyView> |
| 334 | 334 | ) : null} |
| 335 | <ImageViewing | |
| 336 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | |
| 337 | imageIndex={0} | |
| 338 | visible={previewImageUri !== null} | |
| 339 | onRequestClose={() => setPreviewImageUri(null)} | |
| 340 | swipeToCloseEnabled | |
| 341 | doubleTapToZoomEnabled | |
| 342 | /> | |
| 335 | <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} /> | |
| 343 | 336 | </View> |
| 344 | 337 | ); |
| 345 | 338 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21-5| ... | ... | @@ -35,6 +35,7 @@ import { |
| 35 | 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 38 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 38 | 39 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 39 | 40 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 40 | 41 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { |
| 167 | 168 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 168 | 169 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 169 | 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 | 173 | const openVideoPreview = useCallback( |
| 172 | 174 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 173 | wasFocusedBeforeVideoRef.current = isComposerFocused; | |
| 175 | wasFocusedBeforePreviewRef.current = isComposerFocused; | |
| 176 | setPreviewFile(null); | |
| 174 | 177 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 175 | 178 | }, |
| 176 | 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 | 190 | setPreviewVideo(null); |
| 180 | if (wasFocusedBeforeVideoRef.current) { | |
| 191 | setPreviewFile(null); | |
| 192 | if (wasFocusedBeforePreviewRef.current) { | |
| 181 | 193 | setTimeout(() => { |
| 182 | 194 | if (navigation.isFocused()) promptInputRef.current?.focus(); |
| 183 | 195 | }, 100); |
| ... | ... | @@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { |
| 1212 | 1224 | ? () => undefined |
| 1213 | 1225 | : flow.removeAttachment |
| 1214 | 1226 | } |
| 1227 | onPressPreview={ | |
| 1228 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview | |
| 1229 | } | |
| 1215 | 1230 | onPressVideo={ |
| 1216 | 1231 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview |
| 1217 | 1232 | } |
| ... | ... | @@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { |
| 1318 | 1333 | </ComposerDictationToolbar> |
| 1319 | 1334 | </Animated.View> |
| 1320 | 1335 | </ComposerSurface> |
| 1321 | <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} /> | |
| 1336 | <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} /> | |
| 1337 | <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} /> | |
| 1322 | 1338 | </View> |
| 1323 | 1339 | ); |
| 1324 | 1340 |
apps/mobile/src/features/threads/ThreadComposer.tsx+10-17| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | type RefObject, |
| 21 | 21 | } from "react"; |
| 22 | 22 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 23 | import ImageViewing from "react-native-image-viewing"; | |
| 23 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 24 | 24 | import Animated, { |
| 25 | 25 | FadeIn, |
| 26 | 26 | FadeInDown, |
| ... | ... | @@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 311 | 311 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 312 | 312 | const { onExpandedChange } = props; |
| 313 | 313 | |
| 314 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | |
| 314 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | |
| 315 | 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 316 | 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 317 | 317 | const showStopAction = |
| ... | ... | @@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 372 | 372 | onExpandedChange?.(isExpanded); |
| 373 | 373 | }, [isExpanded, onExpandedChange]); |
| 374 | 374 | |
| 375 | const onPressImage = useCallback( | |
| 376 | (uri: string) => { | |
| 375 | const onPressPreview = useCallback( | |
| 376 | (source: FilePreviewSource) => { | |
| 377 | 377 | wasExpandedBeforePreviewRef.current = isFocused; |
| 378 | 378 | setPreviewVideo(null); |
| 379 | setPreviewImageUri(uri); | |
| 379 | setPreviewFile((current) => current ?? source); | |
| 380 | 380 | }, |
| 381 | 381 | [isFocused], |
| 382 | 382 | ); |
| 383 | 383 | |
| 384 | 384 | const closePreview = useCallback(() => { |
| 385 | setPreviewImageUri(null); | |
| 385 | setPreviewFile(null); | |
| 386 | 386 | setPreviewVideo(null); |
| 387 | 387 | if (wasExpandedBeforePreviewRef.current) { |
| 388 | 388 | setTimeout(() => { |
| ... | ... | @@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 394 | 394 | const onPressVideo = useCallback( |
| 395 | 395 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 396 | 396 | wasExpandedBeforePreviewRef.current = isFocused; |
| 397 | setPreviewImageUri(null); | |
| 397 | setPreviewFile(null); | |
| 398 | 398 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 399 | 399 | }, |
| 400 | 400 | [isFocused], |
| ... | ... | @@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 619 | 619 | <ComposerAttachmentStrip |
| 620 | 620 | attachments={props.draftAttachments} |
| 621 | 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 622 | onPressImage={voiceInput.isBusy ? undefined : onPressImage} | |
| 622 | onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} | |
| 623 | 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} |
| 624 | 624 | /> |
| 625 | 625 | </Animated.View> |
| ... | ... | @@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 673 | 673 | size={30} |
| 674 | 674 | borderRadius={8} |
| 675 | 675 | compact |
| 676 | onPressImage={onPressImage} | |
| 676 | onPressPreview={onPressPreview} | |
| 677 | 677 | onPressVideo={onPressVideo} |
| 678 | 678 | /> |
| 679 | 679 | ))} |
| ... | ... | @@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 819 | 819 | </Animated.View> |
| 820 | 820 | |
| 821 | 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> |
| 822 | <ImageViewing | |
| 823 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | |
| 824 | imageIndex={0} | |
| 825 | visible={previewImageUri !== null} | |
| 826 | onRequestClose={closePreview} | |
| 827 | swipeToCloseEnabled | |
| 828 | doubleTapToZoomEnabled | |
| 829 | /> | |
| 822 | <FilePreviewModal source={previewFile} onRequestClose={closePreview} /> | |
| 830 | 823 | </Animated.View> |
| 831 | 824 | ); |
| 832 | 825 | }); |
apps/mobile/src/features/threads/ThreadFeed.tsx+152-79| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | useMemo, |
| 37 | 37 | useRef, |
| 38 | 38 | useState, |
| 39 | useId, | |
| 39 | 40 | type ReactNode, |
| 40 | 41 | type RefObject, |
| 41 | 42 | } from "react"; |
| ... | ... | @@ -62,8 +63,9 @@ import { |
| 62 | 63 | View, |
| 63 | 64 | type ViewStyle, |
| 64 | 65 | } from "react-native"; |
| 65 | import { TouchableOpacity } from "react-native-gesture-handler"; | |
| 66 | import ImageViewing from "react-native-image-viewing"; | |
| 66 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 67 | import { isPdfFile } from "../../lib/filePreview"; | |
| 68 | import { PresentationSource } from "../../components/NativePresentation"; | |
| 67 | 69 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 68 | 70 | import Animated, { |
| 69 | 71 | FadeIn, |
| ... | ... | @@ -212,9 +214,11 @@ export interface ThreadFeedProps { |
| 212 | 214 | function MessageAttachmentImage(props: { |
| 213 | 215 | readonly environmentId: EnvironmentId; |
| 214 | 216 | readonly attachmentId: string; |
| 217 | readonly name: string; | |
| 215 | 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 | 222 | const uri = useAssetUrl(props.environmentId, { |
| 219 | 223 | _tag: "attachment", |
| 220 | 224 | attachmentId: props.attachmentId, |
| ... | ... | @@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { |
| 229 | 233 | } |
| 230 | 234 | |
| 231 | 235 | return ( |
| 232 | <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}> | |
| 233 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> | |
| 234 | </TouchableOpacity> | |
| 236 | <PresentationSource identifier={sourceIdentifier}> | |
| 237 | <Pressable | |
| 238 | accessibilityRole="imagebutton" | |
| 239 | accessibilityLabel={`Open ${props.name}`} | |
| 240 | onPress={() => | |
| 241 | props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) | |
| 242 | } | |
| 243 | > | |
| 244 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> | |
| 245 | </Pressable> | |
| 246 | </PresentationSource> | |
| 235 | 247 | ); |
| 236 | 248 | } |
| 237 | 249 | |
| ... | ... | @@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt |
| 249 | 261 | function MessageAttachmentFile(props: { |
| 250 | 262 | readonly environmentId: EnvironmentId; |
| 251 | 263 | readonly attachment: ChatFileAttachment; |
| 264 | readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 252 | 265 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 253 | 266 | }) { |
| 267 | const sourceIdentifier = useId(); | |
| 254 | 268 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 255 | 269 | reportFailure: false, |
| 256 | 270 | }); |
| 257 | 271 | const preparedConnection = usePreparedConnection(props.environmentId); |
| 258 | 272 | const { attachment } = props; |
| 259 | 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 | 279 | const thumbnailUrl = useAssetUrl( |
| 261 | 280 | props.environmentId, |
| 262 | 281 | videoType === null |
| ... | ... | @@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { |
| 348 | 367 | } |
| 349 | 368 | |
| 350 | 369 | return ( |
| 351 | <Pressable | |
| 352 | accessibilityRole="button" | |
| 353 | accessibilityLabel={`Open ${attachment.name}`} | |
| 354 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | |
| 355 | disabled={opening || httpBaseUrl === null} | |
| 356 | className="flex-row items-center gap-2 py-1" | |
| 357 | onPress={() => shareFile()} | |
| 370 | <PresentationSource | |
| 371 | identifier={sourceIdentifier} | |
| 372 | className="my-1" | |
| 373 | style={{ width: 280, maxWidth: "100%" }} | |
| 358 | 374 | > |
| 359 | {opening ? ( | |
| 360 | <ActivityIndicator size="small" /> | |
| 361 | ) : ( | |
| 362 | <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" /> | |
| 363 | )} | |
| 364 | <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}> | |
| 365 | {attachment.name} | |
| 366 | </Text> | |
| 367 | <Text className="text-xs text-foreground-muted"> | |
| 368 | {formatAttachmentSize(attachment.sizeBytes)} | |
| 369 | </Text> | |
| 370 | </Pressable> | |
| 375 | <Pressable | |
| 376 | accessibilityRole="button" | |
| 377 | accessibilityLabel={`Open ${attachment.name}`} | |
| 378 | accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }} | |
| 379 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | |
| 380 | disabled={opening || httpBaseUrl === null} | |
| 381 | className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle" | |
| 382 | onPress={() => | |
| 383 | isPdf | |
| 384 | ? props.onPressPreview({ | |
| 385 | kind: "pdf", | |
| 386 | name: attachment.name, | |
| 387 | environmentId: props.environmentId, | |
| 388 | resource: { | |
| 389 | _tag: "attachment", | |
| 390 | attachmentId: attachment.id, | |
| 391 | fileName: attachment.name, | |
| 392 | mimeType: "application/pdf", | |
| 393 | }, | |
| 394 | sourceIdentifier, | |
| 395 | }) | |
| 396 | : shareFile(sourceIdentifier) | |
| 397 | } | |
| 398 | > | |
| 399 | <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle"> | |
| 400 | {opening ? ( | |
| 401 | <ActivityIndicator size="small" /> | |
| 402 | ) : ( | |
| 403 | <SymbolView | |
| 404 | name="doc.text" | |
| 405 | size={26} | |
| 406 | tintColorClassName={isPdf ? "accent-red-500" : "accent-foreground-muted"} | |
| 407 | type="monochrome" | |
| 408 | /> | |
| 409 | )} | |
| 410 | </View> | |
| 411 | <View className="min-w-0 flex-1 gap-1"> | |
| 412 | <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}> | |
| 413 | {attachment.name} | |
| 414 | </Text> | |
| 415 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 416 | {fileTypeLabel} · {sizeLabel} | |
| 417 | </Text> | |
| 418 | </View> | |
| 419 | <SymbolView | |
| 420 | name="chevron.right" | |
| 421 | size={12} | |
| 422 | tintColorClassName="accent-foreground-muted" | |
| 423 | type="monochrome" | |
| 424 | /> | |
| 425 | </Pressable> | |
| 426 | </PresentationSource> | |
| 371 | 427 | ); |
| 372 | 428 | } |
| 373 | 429 | |
| ... | ... | @@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { |
| 391 | 447 | readonly sourceKey: string; |
| 392 | 448 | readonly unavailable: boolean; |
| 393 | 449 | readonly alt: string | null; |
| 394 | readonly onPressImage: (uri: string) => void; | |
| 450 | readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 395 | 451 | }) { |
| 452 | const sourceIdentifier = useId(); | |
| 396 | 453 | const [availableWidth, setAvailableWidth] = useState(0); |
| 397 | 454 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 398 | 455 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| ... | ... | @@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { |
| 437 | 494 | )} |
| 438 | 495 | </View> |
| 439 | 496 | ) : ( |
| 440 | <TouchableOpacity | |
| 441 | accessibilityRole="imagebutton" | |
| 442 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 443 | activeOpacity={0.7} | |
| 444 | onPress={() => props.onPressImage(props.uri!)} | |
| 445 | style={{ alignSelf: "flex-start" }} | |
| 446 | > | |
| 447 | <View | |
| 448 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 449 | style={{ | |
| 450 | ...frameStyle, | |
| 451 | }} | |
| 497 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> | |
| 498 | <Pressable | |
| 499 | accessibilityRole="imagebutton" | |
| 500 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 501 | onPress={() => | |
| 502 | props.onPressPreview({ | |
| 503 | kind: "image", | |
| 504 | uri: props.uri!, | |
| 505 | name: props.alt ?? "Image", | |
| 506 | sourceIdentifier, | |
| 507 | }) | |
| 508 | } | |
| 509 | style={{ alignSelf: "flex-start" }} | |
| 452 | 510 | > |
| 453 | <ThreadMarkdownImageRequest | |
| 454 | key={props.uri} | |
| 455 | uri={props.uri} | |
| 456 | onLoad={setSourceSize} | |
| 457 | onError={() => setFailedUri(props.uri)} | |
| 458 | /> | |
| 459 | </View> | |
| 460 | </TouchableOpacity> | |
| 511 | <View | |
| 512 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 513 | style={{ | |
| 514 | ...frameStyle, | |
| 515 | }} | |
| 516 | > | |
| 517 | <ThreadMarkdownImageRequest | |
| 518 | key={props.uri} | |
| 519 | uri={props.uri} | |
| 520 | onLoad={setSourceSize} | |
| 521 | onError={() => setFailedUri(props.uri)} | |
| 522 | /> | |
| 523 | </View> | |
| 524 | </Pressable> | |
| 525 | </PresentationSource> | |
| 461 | 526 | )} |
| 462 | 527 | {props.alt ? ( |
| 463 | 528 | <Text selectable className="text-xs text-foreground-muted"> |
| ... | ... | @@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { |
| 506 | 571 | readonly threadId: ThreadId; |
| 507 | 572 | readonly path: string; |
| 508 | 573 | readonly alt: string | null; |
| 509 | readonly onPressImage: (uri: string) => void; | |
| 574 | readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 510 | 575 | }) { |
| 511 | 576 | const assetUrl = useAssetUrlState(props.environmentId, { |
| 512 | 577 | _tag: "workspace-file", |
| ... | ... | @@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { |
| 520 | 585 | sourceKey={props.path} |
| 521 | 586 | unavailable={assetUrl._tag === "Failure"} |
| 522 | 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 | 597 | sourceKey="unavailable" |
| 533 | 598 | unavailable |
| 534 | 599 | alt={props.alt} |
| 535 | onPressImage={() => undefined} | |
| 600 | onPressPreview={() => undefined} | |
| 536 | 601 | /> |
| 537 | 602 | ); |
| 538 | 603 | } |
| ... | ... | @@ -1264,7 +1329,7 @@ function renderFeedEntry( |
| 1264 | 1329 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 1265 | 1330 | readonly onToggleWorkRow: (rowId: string) => void; |
| 1266 | 1331 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1267 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | |
| 1332 | readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 1268 | 1333 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 1269 | 1334 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1270 | 1335 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| ... | ... | @@ -1375,14 +1440,16 @@ function renderFeedEntry( |
| 1375 | 1440 | key={attachment.id} |
| 1376 | 1441 | environmentId={props.environmentId} |
| 1377 | 1442 | attachmentId={attachment.id} |
| 1443 | name={attachment.name} | |
| 1378 | 1444 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1379 | onPressImage={props.onPressImage} | |
| 1445 | onPressPreview={props.onPressPreview} | |
| 1380 | 1446 | /> |
| 1381 | 1447 | ) : isFileAttachment(attachment) ? ( |
| 1382 | 1448 | <MessageAttachmentFile |
| 1383 | 1449 | key={attachment.id} |
| 1384 | 1450 | environmentId={props.environmentId} |
| 1385 | 1451 | attachment={attachment} |
| 1452 | onPressPreview={props.onPressPreview} | |
| 1386 | 1453 | onPressVideo={props.onPressVideo} |
| 1387 | 1454 | /> |
| 1388 | 1455 | ) : ( |
| ... | ... | @@ -1436,14 +1503,16 @@ function renderFeedEntry( |
| 1436 | 1503 | key={attachment.id} |
| 1437 | 1504 | environmentId={props.environmentId} |
| 1438 | 1505 | attachmentId={attachment.id} |
| 1506 | name={attachment.name} | |
| 1439 | 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 | 1510 | ) : isFileAttachment(attachment) ? ( |
| 1443 | 1511 | <MessageAttachmentFile |
| 1444 | 1512 | key={attachment.id} |
| 1445 | 1513 | environmentId={props.environmentId} |
| 1446 | 1514 | attachment={attachment} |
| 1515 | onPressPreview={props.onPressPreview} | |
| 1447 | 1516 | onPressVideo={props.onPressVideo} |
| 1448 | 1517 | /> |
| 1449 | 1518 | ) : ( |
| ... | ... | @@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1806 | 1875 | expandedTurnIds: new Set(), |
| 1807 | 1876 | }); |
| 1808 | 1877 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; |
| 1809 | const [expandedImage, setExpandedImage] = useState<{ | |
| 1810 | uri: string; | |
| 1811 | headers?: Record<string, string>; | |
| 1812 | } | null>(null); | |
| 1878 | const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); | |
| 1813 | 1879 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); |
| 1814 | 1880 | useEffect(() => { |
| 1815 | 1881 | setExpandedVideo(null); |
| 1882 | setExpandedFile(null); | |
| 1816 | 1883 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); |
| 1817 | 1884 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1818 | 1885 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| ... | ... | @@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1858 | 1925 | ); |
| 1859 | 1926 | if (relativePath) { |
| 1860 | 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 | 1944 | navigation.navigate("ThreadFile", { |
| 1862 | 1945 | environmentId: String(props.environmentId), |
| 1863 | 1946 | threadId: String(props.threadId), |
| ... | ... | @@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1869 | 1952 | } |
| 1870 | 1953 | |
| 1871 | 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 | 1961 | void tryOpenExternalUrl(presentation.href, "markdown-link"); |
| 1873 | 1962 | } |
| 1874 | 1963 | }, |
| ... | ... | @@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1884 | 1973 | sourceKey={imageSource.uri} |
| 1885 | 1974 | unavailable={false} |
| 1886 | 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 | 1986 | threadId={props.threadId} |
| 1898 | 1987 | path={imageSource.path} |
| 1899 | 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 | 2374 | [suspendEndScrollMaintenanceForDisclosure], |
| 2286 | 2375 | ); |
| 2287 | 2376 | |
| 2288 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | |
| 2289 | setExpandedImage({ uri, headers }); | |
| 2377 | const onPressPreview = useCallback((source: FilePreviewSource) => { | |
| 2378 | setExpandedFile((current) => current ?? source); | |
| 2290 | 2379 | }, []); |
| 2291 | 2380 | const onPressVideo = useCallback( |
| 2292 | 2381 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { |
| ... | ... | @@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2350 | 2439 | onToggleWorkGroup, |
| 2351 | 2440 | onToggleWorkRow, |
| 2352 | 2441 | onToggleTurnFold, |
| 2353 | onPressImage, | |
| 2442 | onPressPreview, | |
| 2354 | 2443 | onPressVideo, |
| 2355 | 2444 | onMarkdownLinkPress, |
| 2356 | 2445 | renderMarkdownImage, |
| ... | ... | @@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2379 | 2468 | userBubbleMaxWidth, |
| 2380 | 2469 | onCopyWorkRow, |
| 2381 | 2470 | onMarkdownLinkPress, |
| 2382 | onPressImage, | |
| 2471 | onPressPreview, | |
| 2383 | 2472 | onPressVideo, |
| 2384 | 2473 | onToggleTurnFold, |
| 2385 | 2474 | onToggleWorkGroup, |
| ... | ... | @@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2565 | 2654 | </View> |
| 2566 | 2655 | |
| 2567 | 2656 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> |
| 2568 | <ImageViewing | |
| 2569 | images={ | |
| 2570 | expandedImage | |
| 2571 | ? [ | |
| 2572 | { | |
| 2573 | uri: expandedImage.uri, | |
| 2574 | headers: expandedImage.headers, | |
| 2575 | }, | |
| 2576 | ] | |
| 2577 | : [] | |
| 2578 | } | |
| 2579 | imageIndex={0} | |
| 2580 | visible={expandedImage !== null} | |
| 2581 | onRequestClose={() => setExpandedImage(null)} | |
| 2582 | swipeToCloseEnabled | |
| 2583 | doubleTapToZoomEnabled | |
| 2584 | /> | |
| 2657 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> | |
| 2585 | 2658 | </> |
| 2586 | 2659 | ); |
| 2587 | 2660 | }); |
apps/mobile/src/lib/composerFiles.test.ts+112| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; | |
| 2 | 3 | import type { ImagePickerAsset } from "expo-image-picker"; |
| 3 | 4 | |
| 4 | 5 | const mocks = vi.hoisted(() => ({ |
| ... | ... | @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ |
| 9 | 10 | delete: vi.fn(), |
| 10 | 11 | open: vi.fn(), |
| 11 | 12 | size: vi.fn(), |
| 13 | readBase64: vi.fn(), | |
| 12 | 14 | })); |
| 13 | 15 | |
| 14 | 16 | vi.mock("expo-file-system", () => { |
| ... | ... | @@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { |
| 55 | 57 | mocks.copy(this.uri, destination.uri); |
| 56 | 58 | } |
| 57 | 59 | |
| 60 | async base64(): Promise<string> { | |
| 61 | return mocks.readBase64(this.uri); | |
| 62 | } | |
| 63 | ||
| 58 | 64 | delete(): void { |
| 59 | 65 | mocks.delete(this.uri); |
| 60 | 66 | } |
| ... | ... | @@ -95,9 +101,115 @@ describe("composer file attachments", () => { |
| 95 | 101 | mocks.delete.mockReset(); |
| 96 | 102 | mocks.open.mockReset(); |
| 97 | 103 | mocks.size.mockReset(); |
| 104 | mocks.readBase64.mockReset(); | |
| 98 | 105 | mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); |
| 99 | 106 | }); |
| 100 | 107 | |
| 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 | 213 | describe("photo library videos", () => { |
| 102 | 214 | const image: ImagePickerAsset = { |
| 103 | 215 | uri: "file:///picker/photo.png", |
apps/mobile/src/lib/composerImages.ts+37-11| ... | ... | @@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { |
| 351 | 351 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; |
| 352 | 352 | break; |
| 353 | 353 | } |
| 354 | const mimeType = asset.mimeType?.toLowerCase(); | |
| 354 | let mimeType = asset.mimeType?.toLowerCase(); | |
| 355 | 355 | if (asset.type === "video" || mimeType?.startsWith("video/")) { |
| 356 | 356 | if (input.maxVideoBytes === undefined) { |
| 357 | 357 | error = "Video attachments are unavailable here."; |
| ... | ... | @@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { |
| 375 | 375 | } |
| 376 | 376 | continue; |
| 377 | 377 | } |
| 378 | if (!mimeType?.startsWith("image/")) { | |
| 378 | if (asset.type !== "image" && !mimeType?.startsWith("image/")) { | |
| 379 | 379 | error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; |
| 380 | 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 | } | |
| 386 | 382 | |
| 387 | const base64 = asset.base64; | |
| 383 | let base64 = asset.base64; | |
| 388 | 384 | if (!base64) { |
| 389 | 385 | error = `Failed to read '${asset.fileName ?? "image"}'.`; |
| 390 | 386 | continue; |
| 391 | 387 | } |
| 392 | 388 | |
| 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 | 419 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { |
| 395 | 420 | error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; |
| 396 | 421 | continue; |
| 397 | 422 | } |
| 398 | 423 | |
| 424 | const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 399 | 425 | attachments.push({ |
| 400 | 426 | id: uuidv4(), |
| 401 | 427 | type: "image", |
| 402 | name: asset.fileName ?? "image", | |
| 428 | name, | |
| 403 | 429 | mimeType, |
| 404 | 430 | sizeBytes, |
| 405 | dataUrl: `data:${mimeType};base64,${base64}`, | |
| 406 | previewUri: asset.uri, | |
| 431 | dataUrl, | |
| 432 | previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, | |
| 407 | 433 | }); |
| 408 | 434 | } |
| 409 | 435 |
apps/mobile/src/lib/filePreview.test.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { isPdfFile } from "./filePreview"; | |
| 4 | ||
| 5 | describe("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. */ | |
| 2 | export 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 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const mocks = vi.hoisted(() => ({ | |
| 4 | retain: vi.fn(), | |
| 5 | share: vi.fn(), | |
| 6 | exists: vi.fn(), | |
| 7 | })); | |
| 8 | ||
| 9 | vi.mock("../state/use-composer-drafts", () => ({ | |
| 10 | retainComposerAttachmentFileForPreview: mocks.retain, | |
| 11 | })); | |
| 12 | vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); | |
| 13 | vi.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 | ||
| 27 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | |
| 28 | ||
| 29 | const 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 | ||
| 39 | beforeEach(() => { | |
| 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 | ||
| 48 | describe("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 @@ |
| 1 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 2 | ||
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | |
| 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | |
| 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; | |
| 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | |
| 7 | ||
| 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ | |
| 9 | export 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 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const mocks = vi.hoisted(() => ({ | |
| 4 | retain: vi.fn(), | |
| 5 | share: vi.fn(), | |
| 6 | exists: vi.fn(), | |
| 7 | })); | |
| 8 | ||
| 9 | vi.mock("../state/use-composer-drafts", () => ({ | |
| 10 | retainComposerAttachmentFileForPreview: mocks.retain, | |
| 11 | })); | |
| 12 | vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); | |
| 13 | vi.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 | ||
| 27 | import { loadLocalVideoPreview } from "./localVideoPreview"; | |
| 28 | ||
| 29 | const 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 | ||
| 39 | beforeEach(() => { | |
| 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 | ||
| 48 | describe("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 @@ |
| 1 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 2 | ||
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | |
| 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | |
| 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; | |
| 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | |
| 7 | ||
| 8 | /** Retains the draft original for playback and gives each outgoing share its own lease. */ | |
| 9 | export 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 | 51 | |
| 52 | 52 | ## Native media presentations |
| 53 | 53 | |
| 54 | `PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's | |
| 55 | full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child | |
| 54 | `PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, | |
| 55 | image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child | |
| 56 | 56 | and pass the stable identifier to the presentation. The registry keeps weak |
| 57 | 57 | references to source views; recycled or compact composer thumbnails can register |
| 58 | 58 | the same identifier. Identifiers must distinguish simultaneously visible attachments. |
| 59 | This source registration does not own playback and can be reused by a future image | |
| 60 | viewer. Android uses a regular view. | |
| 59 | The source registration does not own the preview. Android uses a regular view. | |
| 61 | 60 | |
| 62 | 61 | On iOS, video previews mount `AVPlayerViewController` temporarily inside the |
| 63 | 62 | registered 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 | 66 | the player uses a standard modal presentation. Programmatic entry uses the same |
| 68 | 67 | guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; |
| 69 | 68 | if that selector is unavailable, the player also falls back to a standard modal. |
| 70 | Images retain their existing viewer. | |
| 69 | ||
| 70 | `FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, | |
| 71 | or a retained composer file. On iOS, Quick Look owns image and document layout, controls, | |
| 72 | zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail | |
| 73 | and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or | |
| 74 | another image scroll view over that presentation: Quick Look coordinates its image gestures | |
| 75 | with the return to the thumbnail. Missing sources use the standard transition, and Reduce | |
| 76 | Motion disables animation. A pending programmatic Close waits until the current presentation | |
| 77 | or cancelled dismissal has settled before starting another transition. | |
| 78 | ||
| 79 | The shared native presenter copies original bytes into its own temporary directory and | |
| 80 | removes that copy after dismissal. Network downloads write to disk, and sharing never edits | |
| 81 | the source attachment. Draft images use their stored upload data rather than a potentially | |
| 82 | expired picker URI. No React Navigation route or custom transition animator is needed. | |
| 83 | ||
| 84 | The same viewer handles message images, markdown images, PDF attachments and links, | |
| 85 | composer thumbnails, and workspace image previews. The workspace PDF web preview has an | |
| 86 | Open PDF action for the native viewer. Android retains its image viewer and uses the | |
| 87 | system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. | |
| 71 | 88 | |
| 72 | 89 | Received videos open directly from their signed asset URL. AVKit handles buffering; |
| 73 | 90 | the 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 | 20 | Videos use the server's file upload limit. You can also share photos, videos, and files into |
| 21 | 21 | T3 Code from other apps through the system share sheet. Mobile uploads happen when the message |
| 22 | 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile |
| 23 | to save it or open it in another app through the system share sheet. | |
| 23 | to preview it or open the system share options. | |
| 24 | ||
| 25 | Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail | |
| 26 | into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. | |
| 27 | Use Share to save a copy or send it to another app. PDFs support page navigation and search. | |
| 28 | PDF links in assistant responses open the same preview. On Android, images open in the image | |
| 29 | viewer and PDFs open the system chooser. | |
| 24 | 30 | |
| 25 | 31 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 26 | 32 | built-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 | 42 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
| 37 | 43 | |
| 38 | 44 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into |
| 39 | the composer or paste them into a message. | |
| 45 | the composer or paste them into a message. On iOS, selecting them from **Photo Library** also | |
| 46 | converts them to JPEG. The 10 MB image limit applies to the converted photo. | |
| 40 | 47 | |
| 41 | 48 | On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, |
| 42 | 49 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |