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