diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8bfc151487a36f1a944a6fd3e45b58ba18d29ad2..c4a7717cd46ae239473496e1951e39393fb36957 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -206,6 +206,7 @@ const config: ExpoConfig = { }, NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", + NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", ITSAppUsesNonExemptEncryption: false, // The App Store screenshot harness rotates the iPad interface from // inside the app (CI denies osascript the Accessibility access that diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 4f9073860723d02f58e6375b9d7269ede190f178..ddc8a80270faa8d338e62604342d70a64ab782fc 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -5,6 +5,7 @@ import UIKit public final class T3NativeControlsModule: Module { private let presentationSources = T3PresentationSources() private var videoPresentation: T3NativeVideoPresentation? + private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { Name("T3NativeControls") @@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { self.dismissVideo(identifier: identifier) }.runOnQueue(.main) + AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, + identifier: identifier, promise: promise) + }.runOnQueue(.main) + + AsyncFunction("dismissFile") { (identifier: String) in + self.dismissFile(identifier: identifier) + }.runOnQueue(.main) + OnDestroy { let presentation = self.videoPresentation - DispatchQueue.main.async { presentation?.dismiss() } + let file = self.filePresentation + DispatchQueue.main.async { + presentation?.dismiss() + file?.dismiss() + } } View(T3PresentationSourceView.self) { @@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { let isPlayableURL = url.isFileURL ? FileManager.default.isReadableFile(atPath: url.path) : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) - guard videoPresentation == nil, + guard videoPresentation == nil, filePresentation == nil, let presenter = appContext?.utilities?.currentViewController(), isPlayableURL else { @@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } } + private func presentFile(url: URL, title: String, sourceIdentifier: String, + identifier: String, promise: Promise) throws { + guard filePresentation == nil, videoPresentation == nil, + let presenter = appContext?.utilities?.currentViewController() + else { throw URLError(.cannotLoadFromNetwork) } + let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, + sourceIdentifier: sourceIdentifier) { [weak self] error in + self?.filePresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + filePresentation = file + file.present(url: url, title: title, from: presenter) + } + + private func dismissFile(identifier: String) { + if filePresentation?.identifier == identifier { filePresentation?.dismiss() } + } + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { guard let presenter = appContext?.utilities?.currentViewController() else { throw NSError( diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift new file mode 100644 index 0000000000000000000000000000000000000000..1a7009c3821dfecc76c8f2bf812beab6707ffea3 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -0,0 +1,165 @@ +import ImageIO +import QuickLook +import UIKit +import UniformTypeIdentifiers + +private final class FilePreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? + var previewItemTitle: String? +} + +private final class FilePreviewController: QLPreviewController { + var onAppear: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppear?() + } +} + +/// Quick Look owns image and document controls, zooming, and source-view transitions. +final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, + QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { + let identifier: String + private var controller: UIViewController? + private let completion: (Error?) -> Void + private weak var sources: T3PresentationSources? + private let sourceIdentifier: String + private let item = FilePreviewItem() + private var loading: Task? + private var dismissRequested = false + private var finished = false + + init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.sources = sources + self.sourceIdentifier = sourceIdentifier + self.completion = completion + super.init() + } + + func present(url: URL, title: String, from presenter: UIViewController) { + loading = Task { @MainActor [self] in + do { + let file = try await Self.prepareFile(url: url, title: title) + guard !finished, !Task.isCancelled else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + return + } + item.previewItemURL = file + item.previewItemTitle = title + let preview = FilePreviewController() + preview.delegate = self + preview.dataSource = self + preview.onAppear = { [weak self] in self?.resumePendingDismissal() } + controller = preview + presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in + resumePendingDismissal() + } + preview.presentationController?.delegate = self + } catch { + finish(error: error) + } + } + } + + func dismiss() { + dismissRequested = true + loading?.cancel() + guard !finished else { return } + guard let controller else { finish(); return } + // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. + // Starting a second modal transition while UIKit is settling the first can strand it. + guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } + controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } + } + + private func resumePendingDismissal() { + // Appearance callbacks run before UIKit has cleared the current transition. + DispatchQueue.main.async { [weak self] in + if self?.dismissRequested == true { self?.dismiss() } + } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + item + } + + func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { + guard !UIAccessibility.isReduceMotionEnabled else { return nil } + return sources?.view(for: sourceIdentifier) + } + + func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, + inSourceView view: AutoreleasingUnsafeMutablePointer) -> CGRect { + guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } + view.pointee = source + return source.bounds + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } + + private func finish(error: Error? = nil) { + guard !finished else { return } + finished = true + loading?.cancel() + loading = nil + if let file = item.previewItemURL { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + item.previewItemURL = nil + DispatchQueue.main.async { [completion] in completion(error) } + } + + /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. + nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + let download = directory.appendingPathComponent("original") + if url.isFileURL { + try FileManager.default.copyItem(at: url, to: download) + } else if url.scheme == "data" { + try Data(contentsOf: url).write(to: download, options: .atomic) + } else { + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { + throw URLError(.unsupportedURL) + } + let (temporaryFile, response) = try await URLSession.shared.download(from: url) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + throw URLError(.badServerResponse) + } + try FileManager.default.moveItem(at: temporaryFile, to: download) + } + try Task.checkCancellation() + let type: UTType + if let image = CGImageSourceCreateWithURL(download as CFURL, nil), + CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), + let detectedType = UTType(imageType as String) { + type = detectedType + } else if CGPDFDocument(download as CFURL) != nil { + type = .pdf + } else { + throw URLError(.cannotDecodeContentData) + } + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString + let originalExtension = filename.pathExtension + let fileExtension = UTType(filenameExtension: originalExtension) == type + ? originalExtension : type.preferredFilenameExtension ?? "png" + let stem = filename.deletingPathExtension + var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") + while name.utf8.count > 200 { name.removeLast() } + let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") + try FileManager.default.moveItem(at: download, to: file) + return file + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } +} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index b18777c022600e0b0df10b99575c7fd116f79aa0..96640f19583698e385de9e42e6b69eee9c93539a 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { PresentationSource } from "./NativePresentation"; +import type { FilePreviewSource } from "./FilePreviewModal"; +import { isPdfFile } from "../lib/filePreview"; export interface ComposerAttachmentStripProps { /** Attachments to display. */ readonly attachments: ReadonlyArray; /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; - /** Called when the user taps on an image thumbnail to preview it. */ - readonly onPressImage?: (previewUri: string) => void; + /** Called when the user taps an image or PDF to preview it. */ + readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, @@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { readonly size: number; readonly borderRadius: number; readonly compact?: boolean; - readonly onPressImage?: (previewUri: string) => void; + readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, @@ -41,46 +44,74 @@ export function ComposerAttachmentThumbnail(props: { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; if (attachment.type === "image") { + const sourceIdentifier = `draft-image:${attachment.id}`; return ( + + + props.onPressPreview?.({ + kind: "image", + uri: attachment.dataUrl, + name: attachment.name, + sourceIdentifier, + }) + } + > + + + + ); + } + const onPressVideo = props.onPressVideo; + if (onPressVideo && videoMimeType(attachment) !== null) { + return ( + + ); + } + const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; + const sourceIdentifier = `draft-file:${attachment.id}`; + return ( + props.onPressImage?.(attachment.previewUri) : undefined} + accessibilityRole="button" + accessibilityLabel={`Open ${attachment.name}`} + disabled={!canPreview} + onPress={() => + props.onPressPreview?.({ + kind: "pdf", + name: attachment.name, + attachment, + sourceIdentifier, + }) + } + className={ + props.compact + ? "items-center justify-center bg-subtle" + : "items-center justify-center gap-1 bg-subtle px-2" + } + style={style} > - + {!props.compact ? ( + + {attachment.name} + + ) : null} - ); - } - const onPressVideo = props.onPressVideo; - if (onPressVideo && videoMimeType(attachment) !== null) { - return ( - - ); - } - return ( - - - {!props.compact ? ( - - {attachment.name} - - ) : null} - + ); } @@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { shareRef.current = controller; setSharing(true); void (async () => { - const preview = await loadLocalVideoPreview(attachment, controller.signal); + const preview = await loadLocalAttachmentPreview(attachment, controller.signal); if (!preview) return; try { await preview.share(controller.signal, sourceIdentifier); @@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { attachment={attachment} size={size} borderRadius={radius} - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} onPressVideo={props.onPressVideo} /> ; + dismissFile(identifier: string): Promise; +}>("T3NativeControls"); + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name, sourceIdentifier } = props.source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + + useEffect(() => { + let canceled = false; + void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) + .catch(() => { + if (!canceled) { + Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); + } + }) + .finally(() => { + if (!canceled) onRequestClose(); + }); + return () => { + canceled = true; + void NativeControls.dismissFile(identifier).catch(() => undefined); + }; + }, [uri, name, sourceIdentifier, identifier]); + + return null; +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f10bfb8b3e63b8f8feb51511e7124348c6f7b444 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.tsx @@ -0,0 +1,52 @@ +import { useEffect, useEffectEvent } from "react"; +import { Alert } from "react-native"; +import ImageViewing from "react-native-image-viewing"; + +import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; + +function PdfPreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name } = props.source; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + const controller = new AbortController(); + const input = { + attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, + signal: controller.signal, + }; + // Android's system chooser supplies the installed PDF apps. + const opened = + uri.startsWith("file:") || uri.startsWith("content:") + ? shareLocalAttachment({ ...input, uri }) + : downloadAndShareAttachment({ ...input, url: uri }); + void opened + .catch(() => { + if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); + }) + .finally(() => { + if (!controller.signal.aborted) onRequestClose(); + }); + return () => controller.abort(); + }, [uri, name]); + return null; +} + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + if (props.source.kind === "pdf") return ; + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9df7e892c27784821bc18918b6a2f9e85fca87c --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -0,0 +1,93 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { FilePreview } from "./FilePreview"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf"; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); + +function ResolvedFilePreview(props: { + readonly source: FilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. + const [uri, setUri] = useState("uri" in source ? source.uri : null); + const onRequestClose = useEffectEvent(props.onRequestClose); + const failed = + environmentId !== null && + uri === null && + (connection._tag === "None" || asset._tag === "Failure"); + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (uri === null && asset._tag === "Success") setUri(asset.url); + }, [uri, asset]); + useEffect(() => { + if (!failed) return; + Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + onRequestClose(); + }, [failed]); + useEffect(() => { + if (!("attachment" in source)) return; + const controller = new AbortController(); + let release: (() => void) | undefined; + void loadLocalAttachmentPreview(source.attachment, controller.signal) + .then((file) => { + if (!file) return; + if (controller.signal.aborted) { + file.dispose(); + return; + } + release = file.dispose; + setUri(file.uri); + }) + .catch(() => { + if (controller.signal.aborted) return; + Alert.alert("Could not open preview", "Attach the file again and retry."); + onRequestClose(); + }); + return () => { + controller.abort(); + release?.(); + }; + }, [source]); + + return uri === null ? null : ( + + ); +} + +export function FilePreviewModal(props: { + readonly source: FilePreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 4b2cfe27e6ff9b206ec640187f3ec96df7cafc5d..a947d8d2e51cc897f8fbdab41dcf1ba5092546fe 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -4,7 +4,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import type { VideoPreviewSource } from "./VideoPreviewModal"; @@ -67,7 +67,7 @@ function NativeVideoPreview(props: { void (async () => { const file = source.type === "local" - ? await loadLocalVideoPreview(source.attachment, controller.signal) + ? await loadLocalAttachmentPreview(source.attachment, controller.signal) : null; if (source.type === "local" && !file) return; try { diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 190f5c7b01bb5649bc657d9b53e6c8f48e35232e..eaa01c5d1714fda4d67d69c58973482f2172d06f 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -20,7 +20,7 @@ import { type AttachmentPreviewFile, } from "../lib/attachmentDownload"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { SymbolView } from "./AppSymbol"; @@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { setFailure(null); const loading = source.type === "local" - ? loadLocalVideoPreview(source.attachment, controller.signal) + ? loadLocalAttachmentPreview(source.attachment, controller.signal) : downloadAttachmentForPreview({ url: downloadUrl!, attachment: { name: attachment.name, mimeType }, diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 5af33499e4d13f0edb957165731da17c5bd9700d..0be94c700ce68438e97ac21770d3fcfdb08587d9 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { StyleSheet } from "react-native"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; export function VideoThumbnailImage(props: { @@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { async (signal) => typeof source === "string" ? { uri: source, dispose: () => undefined } - : loadLocalVideoPreview(source, signal), + : loadLocalAttachmentPreview(source, signal), controller.signal, ).then((thumbnail) => { if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 462d075324dd0ac524b32fdcb858a81b2bc78f10..5dddac1dd820860ebdd7b7c6de820205d7de0e79 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadSelection } from "../../state/use-thread-selection"; @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const [fullScreenPreview, setFullScreenPreview] = useState(null); const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); const canPreview = @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { inline: false, onPress: () => copyTextWithHaptic(relativePath), } as const, + isPdfFile({ name: relativePath }) && previewUri !== null + ? ({ + id: "open-pdf", + title: "Open PDF", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => + setFullScreenPreview({ + kind: "pdf", + uri: previewUri, + name: basename(relativePath), + }), + } as const) + : null, isBrowserFile && typeof assetPreviewUri === "string" ? ({ id: "open-browser", @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } as const) : null, ].filter((action) => action !== null); - }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); + }, [ + assetPreviewUri, + previewUri, + canPreview, + isBrowserFile, + isImageFile, + relativePath, + resolvedActiveMode, + ]); const androidFileMenuActions = useMemo( () => @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { truncated={fileData?.truncated ?? false} onRefresh={() => fileQuery.refresh()} /> + setFullScreenPreview(null)} + /> ); diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 73eca66bf99932c565b15f39dc6ebb2dbef7e19a..e725c4d133f660f031f56d248a01a8030086db70 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,24 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { PresentationSource } from "../../components/NativePresentation"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [preview, setPreview] = useState(null); + const sourceIdentifier = useId(); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} disabled={loadError !== null} className="flex-1 p-4 active:bg-subtle-strong" - onPress={() => setFullScreenVisible(true)} + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + }) + } > - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + {loadError !== null ? ( @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index a798e5ebe4ae1202d4bab2b9173d49564eae1dfc..74ccc8cf0bcbc5b8a915a133c3af6ec1ccbdf262 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp Record> >({}); const [attachments, setAttachments] = useState>([]); - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp attachments={attachments} imageBorderRadius={16} imageSize={60} - onPressImage={setPreviewImageUri} + onPressPreview={setPreviewFile} removeButtonPlacement="gutter" onRemove={(imageId) => { setAttachments((current) => @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp ) : null} - setPreviewImageUri(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreviewFile(null)} /> ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 96445146e158fad3d91229ddf2a26396ffb832a0..f7b9f0e2c97c16ee3993a3f797dfd742d9b9d56d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -35,6 +35,7 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; @@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const [previewVideo, setPreviewVideo] = useState(null); - const wasFocusedBeforeVideoRef = useRef(false); + const [previewFile, setPreviewFile] = useState(null); + const wasFocusedBeforePreviewRef = useRef(false); const openVideoPreview = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { - wasFocusedBeforeVideoRef.current = isComposerFocused; + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isComposerFocused], ); - const closeVideoPreview = useCallback(() => { + const openFilePreview = useCallback( + (source: FilePreviewSource) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); + }, + [isComposerFocused], + ); + const closeMediaPreview = useCallback(() => { setPreviewVideo(null); - if (wasFocusedBeforeVideoRef.current) { + setPreviewFile(null); + if (wasFocusedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) promptInputRef.current?.focus(); }, 100); @@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { ? () => undefined : flow.removeAttachment } + onPressPreview={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview + } onPressVideo={ isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview } @@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { - + + ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e1549cef96c2539c5008384f816545b9ef9c8088..786b407308b8caf302074677f17f0b523d33d819 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -20,7 +20,7 @@ import { type RefObject, } from "react"; import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import Animated, { FadeIn, FadeInDown, @@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = @@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); - const onPressImage = useCallback( - (uri: string) => { + const onPressPreview = useCallback( + (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewVideo(null); - setPreviewImageUri(uri); + setPreviewFile((current) => current ?? source); }, [isFocused], ); const closePreview = useCallback(() => { - setPreviewImageUri(null); + setPreviewFile(null); setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => { @@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const onPressVideo = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasExpandedBeforePreviewRef.current = isFocused; - setPreviewImageUri(null); + setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isFocused], @@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer undefined : props.onRemoveDraftImage} - onPressImage={voiceInput.isBusy ? undefined : onPressImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} /> @@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer size={30} borderRadius={8} compact - onPressImage={onPressImage} + onPressPreview={onPressPreview} onPressVideo={onPressVideo} /> ))} @@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 24af82389036e2209e95eb9de748354e21acd492..8234a3c44f4345858b871cb3c4ad25b02d214a32 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -36,6 +36,7 @@ import { useMemo, useRef, useState, + useId, type ReactNode, type RefObject, } from "react"; @@ -62,8 +63,9 @@ import { View, type ViewStyle, } from "react-native"; -import { TouchableOpacity } from "react-native-gesture-handler"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { isPdfFile } from "../../lib/filePreview"; +import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, @@ -212,9 +214,11 @@ export interface ThreadFeedProps { function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; + readonly name: string; readonly className: string; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const uri = useAssetUrl(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, @@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - - + + + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + } + > + + + ); } @@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; + readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { + const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); + const isPdf = isPdfFile(attachment); + const fileTypeLabel = isPdf + ? "PDF" + : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); + const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const thumbnailUrl = useAssetUrl( props.environmentId, videoType === null @@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { } return ( - shareFile()} + - {opening ? ( - - ) : ( - - )} - - {attachment.name} - - - {formatAttachmentSize(attachment.sizeBytes)} - - + + isPdf + ? props.onPressPreview({ + kind: "pdf", + name: attachment.name, + environmentId: props.environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: "application/pdf", + }, + sourceIdentifier, + }) + : shareFile(sourceIdentifier) + } + > + + {opening ? ( + + ) : ( + + )} + + + + {attachment.name} + + + {fileTypeLabel} ยท {sizeLabel} + + + + + ); } @@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { )} ) : ( - props.onPressImage(props.uri!)} - style={{ alignSelf: "flex-start" }} - > - + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + }) + } + style={{ alignSelf: "flex-start" }} > - setFailedUri(props.uri)} - /> - - + + setFailedUri(props.uri)} + /> + + + )} {props.alt ? ( @@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { readonly threadId: ThreadId; readonly path: string; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { const assetUrl = useAssetUrlState(props.environmentId, { _tag: "workspace-file", @@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { sourceKey={props.path} unavailable={assetUrl._tag === "Failure"} alt={props.alt} - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ); } @@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) sourceKey="unavailable" unavailable alt={props.alt} - onPressImage={() => undefined} + onPressPreview={() => undefined} /> ); } @@ -1264,7 +1329,7 @@ function renderFeedEntry( readonly onToggleWorkGroup: (groupId: string) => void; readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; @@ -1375,14 +1440,16 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1436,14 +1503,16 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; - const [expandedImage, setExpandedImage] = useState<{ - uri: string; - headers?: Record; - } | null>(null); + const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); useEffect(() => { setExpandedVideo(null); + setExpandedFile(null); }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ @@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); + if (isPdfFile({ name: relativePath })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: relativePath.split("/").at(-1), + environmentId: props.environmentId, + resource: { + _tag: "workspace-file", + threadId: props.threadId, + path: relativePath, + }, + }, + ); + return; + } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), @@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { + setExpandedFile( + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + ); + return; + } void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, @@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { sourceKey={imageSource.uri} unavailable={false} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); } @@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId={props.threadId} path={imageSource.path} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); }, @@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [suspendEndScrollMaintenanceForDisclosure], ); - const onPressImage = useCallback((uri: string, headers?: Record) => { - setExpandedImage({ uri, headers }); + const onPressPreview = useCallback((source: FilePreviewSource) => { + setExpandedFile((current) => current ?? source); }, []); const onPressVideo = useCallback( (attachment: ChatFileAttachment, sourceIdentifier: string) => { @@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, - onPressImage, + onPressPreview, onPressVideo, onMarkdownLinkPress, renderMarkdownImage, @@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, - onPressImage, + onPressPreview, onPressVideo, onToggleTurnFold, onToggleWorkGroup, @@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo(null)} /> - setExpandedImage(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setExpandedFile(null)} /> ); }); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 561b2a800aeb82da59df9b8fd27e3ddb22fd4be0..b38c0813c6a1b1dd82ad34bf1300bdc5293f0013 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import type { ImagePickerAsset } from "expo-image-picker"; const mocks = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ delete: vi.fn(), open: vi.fn(), size: vi.fn(), + readBase64: vi.fn(), })); vi.mock("expo-file-system", () => { @@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { mocks.copy(this.uri, destination.uri); } + async base64(): Promise { + return mocks.readBase64(this.uri); + } + delete(): void { mocks.delete(this.uri); } @@ -95,9 +101,115 @@ describe("composer file attachments", () => { mocks.delete.mockReset(); mocks.open.mockReset(); mocks.size.mockReset(); + mocks.readBase64.mockReset(); mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); }); + describe("photo library image conversion", () => { + const jpeg = "/9j/2Q=="; + const photo: ImagePickerAsset = { + uri: "file:///picker/photo.heic", + type: "image", + fileName: "photo.HEIC", + mimeType: "image/heic", + fileSize: 20 * 1024 * 1024, + base64: jpeg, + width: 1, + height: 1, + }; + + it.each(["image/heic", "image/heif", undefined])( + "attaches the native JPEG conversion with matching metadata when the source MIME is %s", + async (mimeType) => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, mimeType }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result).toEqual({ + images: [ + { + id: "attachment-id", + type: "image", + name: "photo.jpg", + mimeType: "image/jpeg", + sizeBytes: 4, + dataUrl: `data:image/jpeg;base64,${jpeg}`, + previewUri: `data:image/jpeg;base64,${jpeg}`, + }, + ], + error: null, + }); + }, + ); + + it.each([ + { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, + { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, + { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, + ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { + const name = `photo.${original.extension}`; + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: name, mimeType: original.mimeType }], + }); + mocks.readBase64.mockResolvedValue(original.base64); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.images).toEqual([ + expect.objectContaining({ + name, + mimeType: original.mimeType, + dataUrl: `data:${original.mimeType};base64,${original.base64}`, + sizeBytes: Buffer.from(original.base64, "base64").byteLength, + }), + ]); + }); + + it("checks the converted JPEG size even when the HEIC source was smaller", async () => { + const oversized = + jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileSize: 42, base64: oversized }], + }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + }); + }); + + it("does not relabel unconverted HEIC bytes as JPEG", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([]); + expect(result.error).toContain("not a supported image type"); + }); + + it("retains a converted photo when another original cannot be read", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], + }); + mocks.readBase64.mockRejectedValue(new Error("missing file")); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); + expect(result.error).toBe("Failed to read 'missing.gif'."); + }); + }); + describe("photo library videos", () => { const image: ImagePickerAsset = { uri: "file:///picker/photo.png", diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index b4684e5bfc6f9cf4a125d3afdeb8e1ea3ff07586..1a66c03c706084ba257c85732938941deaaf98dd 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } - const mimeType = asset.mimeType?.toLowerCase(); + let mimeType = asset.mimeType?.toLowerCase(); if (asset.type === "video" || mimeType?.startsWith("video/")) { if (input.maxVideoBytes === undefined) { error = "Video attachments are unavailable here."; @@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { } continue; } - if (!mimeType?.startsWith("image/")) { + if (asset.type !== "image" && !mimeType?.startsWith("image/")) { error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } - if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { - error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; - continue; - } - const base64 = asset.base64; + let base64 = asset.base64; if (!base64) { error = `Failed to read '${asset.fileName ?? "image"}'.`; continue; } - const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + let name = asset.fileName?.trim() || "image"; + // The iOS picker returns JPEG base64 even when its metadata describes HEIC, + // PNG, or GIF. Keep supported originals so transparency and animation survive; + // use the native JPEG conversion for formats providers cannot accept. + if (base64.startsWith("/9j/")) { + if ( + mimeType && + mimeType !== "image/jpeg" && + isProviderSendTurnSupportedImageMimeType(mimeType) + ) { + try { + const { File } = await import("expo-file-system"); + base64 = await new File(asset.uri).base64(); + } catch { + error = `Failed to read '${name}'.`; + continue; + } + } else { + mimeType = "image/jpeg"; + if (!/\.jpe?g$/i.test(name)) { + name = `${name.replace(/\.[^.]+$/, "")}.jpg`; + } + } + } + if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { + error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } + + const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; continue; } + const dataUrl = `data:${mimeType};base64,${base64}`; attachments.push({ id: uuidv4(), type: "image", - name: asset.fileName ?? "image", + name, mimeType, sizeBytes, - dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: asset.uri, + dataUrl, + previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, }); } diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..50be5369c7d3f32693b81c8b5904ed4ef14742f4 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPdfFile } from "./filePreview"; + +describe("PDF preview detection", () => { + it.each([ + [{ name: "download", mimeType: "application/pdf" }, true], + [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], + [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], + [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], + [{ name: "report.pdf", mimeType: "text/plain" }, false], + [{ name: "report.pdf.exe" }, false], + [{ name: "https://example.com/page?download=report.pdf" }, false], + ])("classifies %j as %s", (file, expected) => { + expect(isPdfFile(file)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/filePreview.ts b/apps/mobile/src/lib/filePreview.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ee96476d720d5017306fa5225664c9f4c0e59f1 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.ts @@ -0,0 +1,6 @@ +/** MIME metadata wins; use the extension for files reported without a specific type. */ +export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean { + const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); + if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf"; + return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? ""); +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ed83b26f6405b9b4b732cc5b5501d9aaa1568ad --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + retain: vi.fn(), + share: vi.fn(), + exists: vi.fn(), +})); + +vi.mock("../state/use-composer-drafts", () => ({ + retainComposerAttachmentFileForPreview: mocks.retain, +})); +vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); +vi.mock("expo-file-system", () => ({ + File: class { + constructor(readonly uri: string) {} + get exists(): boolean { + return mocks.exists(this.uri); + } + }, + Paths: { + document: { + uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/", + }, + }, +})); + +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; + +const attachment = { + type: "file" as const, + id: "draft-video", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 12, + fileUri: + "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov", +}; + +beforeEach(() => { + mocks.retain.mockReset(); + mocks.share.mockReset(); + mocks.exists.mockReset(); + mocks.retain.mockImplementation(() => vi.fn()); + mocks.exists.mockReturnValue(true); + mocks.share.mockResolvedValue(undefined); +}); + +describe("loadLocalAttachmentPreview", () => { + it("retains and shares a PDF with its original filename and type", async () => { + const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; + const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); + await preview!.share(new AbortController().signal); + expect(mocks.share).toHaveBeenCalledWith( + expect.objectContaining({ + attachment: { name: "report.pdf", mimeType: "application/pdf" }, + }), + ); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + it("resolves the current iOS container and releases its playback lease once", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); + expect(mocks.retain).toHaveBeenCalledWith(attachment); + const release = mocks.retain.mock.results[0]!.value; + expect(release).not.toHaveBeenCalled(); + preview?.dispose(); + preview?.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a separate share lease after playback closes (source: %s)", + async (sourceIdentifier) => { + const shared = Promise.withResolvers(); + mocks.share.mockReturnValue(shared.promise); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + const share = preview!.share(new AbortController().signal, sourceIdentifier); + expect(mocks.retain).toHaveBeenCalledTimes(2); + const releasePlayback = mocks.retain.mock.results[0]!.value; + const releaseShare = mocks.retain.mock.results[1]!.value; + preview!.dispose(); + expect(releasePlayback).toHaveBeenCalledTimes(1); + expect(releaseShare).not.toHaveBeenCalled(); + shared.resolve(); + await share; + expect(releaseShare).toHaveBeenCalledTimes(1); + }, + ); + + it("releases a failed share while keeping playback retained", async () => { + mocks.share.mockRejectedValue(new Error("Sharing unavailable")); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + await expect(preview!.share(new AbortController().signal)).rejects.toThrow( + "Sharing unavailable", + ); + expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + }); + + it("releases a load canceled during native module loading", async () => { + const controller = new AbortController(); + const loading = loadLocalAttachmentPreview(attachment, controller.signal); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + expect(mocks.exists).not.toHaveBeenCalled(); + }); + + it("reports missing files and releases their lease", async () => { + mocks.exists.mockReturnValue(false); + await expect( + loadLocalAttachmentPreview(attachment, new AbortController().signal), + ).rejects.toThrow("This attachment is no longer available. Attach the file again."); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + + it("does not start sharing a disposed preview", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + preview!.dispose(); + await preview!.share(new AbortController().signal); + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.retain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdd20e2e63d576acc3633e5a50505a1c06486368 --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -0,0 +1,59 @@ +import { videoMimeType } from "@t3tools/shared/video"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; +import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; + +/** Retains the draft original for preview and gives each outgoing share its own lease. */ +export async function loadLocalAttachmentPreview( + attachment: DraftComposerFileAttachment, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null; + const release = retainComposerAttachmentFileForPreview(attachment); + try { + const { File, Paths } = await import("expo-file-system"); + if (signal.aborted) { + release(); + return null; + } + const uri = + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri; + const file = new File(uri); + if (!file.exists) { + throw new Error("The local attachment file is missing."); + } + let disposed = false; + return { + uri: file.uri, + dispose: () => { + if (disposed) return; + disposed = true; + release(); + }, + share: async (shareSignal, sourceIdentifier) => { + if (disposed || shareSignal.aborted) return; + const releaseShare = retainComposerAttachmentFileForPreview(attachment); + try { + await shareLocalAttachment({ + uri: file.uri, + attachment: { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + signal: shareSignal, + sourceIdentifier, + }); + } finally { + releaseShare(); + } + }, + }; + } catch (cause) { + release(); + if (signal.aborted) return null; + throw new Error("This attachment is no longer available. Attach the file again.", { cause }); + } +} diff --git a/apps/mobile/src/lib/localVideoPreview.test.ts b/apps/mobile/src/lib/localVideoPreview.test.ts deleted file mode 100644 index 706c615216823fc66efb8bece7f091f4b4062db0..0000000000000000000000000000000000000000 --- a/apps/mobile/src/lib/localVideoPreview.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - retain: vi.fn(), - share: vi.fn(), - exists: vi.fn(), -})); - -vi.mock("../state/use-composer-drafts", () => ({ - retainComposerAttachmentFileForPreview: mocks.retain, -})); -vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); -vi.mock("expo-file-system", () => ({ - File: class { - constructor(readonly uri: string) {} - get exists(): boolean { - return mocks.exists(this.uri); - } - }, - Paths: { - document: { - uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/", - }, - }, -})); - -import { loadLocalVideoPreview } from "./localVideoPreview"; - -const attachment = { - type: "file" as const, - id: "draft-video", - name: "clip.mov", - mimeType: "video/quicktime", - sizeBytes: 12, - fileUri: - "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov", -}; - -beforeEach(() => { - mocks.retain.mockReset(); - mocks.share.mockReset(); - mocks.exists.mockReset(); - mocks.retain.mockImplementation(() => vi.fn()); - mocks.exists.mockReturnValue(true); - mocks.share.mockResolvedValue(undefined); -}); - -describe("loadLocalVideoPreview", () => { - it("resolves the current iOS container and releases its playback lease once", async () => { - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); - expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); - expect(mocks.retain).toHaveBeenCalledWith(attachment); - const release = mocks.retain.mock.results[0]!.value; - expect(release).not.toHaveBeenCalled(); - preview?.dispose(); - preview?.dispose(); - expect(release).toHaveBeenCalledTimes(1); - }); - - it.each([undefined, "share-button"])( - "keeps a separate share lease after playback closes (source: %s)", - async (sourceIdentifier) => { - const shared = Promise.withResolvers(); - mocks.share.mockReturnValue(shared.promise); - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); - const share = preview!.share(new AbortController().signal, sourceIdentifier); - expect(mocks.retain).toHaveBeenCalledTimes(2); - const releasePlayback = mocks.retain.mock.results[0]!.value; - const releaseShare = mocks.retain.mock.results[1]!.value; - preview!.dispose(); - expect(releasePlayback).toHaveBeenCalledTimes(1); - expect(releaseShare).not.toHaveBeenCalled(); - shared.resolve(); - await share; - expect(releaseShare).toHaveBeenCalledTimes(1); - }, - ); - - it("releases a failed share while keeping playback retained", async () => { - mocks.share.mockRejectedValue(new Error("Sharing unavailable")); - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); - await expect(preview!.share(new AbortController().signal)).rejects.toThrow( - "Sharing unavailable", - ); - expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); - expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); - preview!.dispose(); - }); - - it("releases a load canceled during native module loading", async () => { - const controller = new AbortController(); - const loading = loadLocalVideoPreview(attachment, controller.signal); - controller.abort(); - await expect(loading).resolves.toBeNull(); - expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); - expect(mocks.exists).not.toHaveBeenCalled(); - }); - - it("reports missing files and releases their lease", async () => { - mocks.exists.mockReturnValue(false); - await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow( - "This video is no longer available. Attach the file again.", - ); - expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); - }); - - it("does not start sharing a disposed preview", async () => { - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); - preview!.dispose(); - await preview!.share(new AbortController().signal); - expect(mocks.share).not.toHaveBeenCalled(); - expect(mocks.retain).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/mobile/src/lib/localVideoPreview.ts b/apps/mobile/src/lib/localVideoPreview.ts deleted file mode 100644 index 8398124b96cef57b5206b33ebab33d958f06cd6c..0000000000000000000000000000000000000000 --- a/apps/mobile/src/lib/localVideoPreview.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { videoMimeType } from "@t3tools/shared/video"; - -import type { DraftComposerFileAttachment } from "./composerImages"; -import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; -import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; - -/** Retains the draft original for playback and gives each outgoing share its own lease. */ -export async function loadLocalVideoPreview( - attachment: DraftComposerFileAttachment, - signal: AbortSignal, -): Promise { - if (signal.aborted) return null; - const release = retainComposerAttachmentFileForPreview(attachment); - try { - const { File, Paths } = await import("expo-file-system"); - if (signal.aborted) { - release(); - return null; - } - const uri = - resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? - attachment.fileUri; - const file = new File(uri); - if (!file.exists) { - throw new Error("The local attachment file is missing."); - } - let disposed = false; - return { - uri: file.uri, - dispose: () => { - if (disposed) return; - disposed = true; - release(); - }, - share: async (shareSignal, sourceIdentifier) => { - if (disposed || shareSignal.aborted) return; - const releaseShare = retainComposerAttachmentFileForPreview(attachment); - try { - await shareLocalAttachment({ - uri: file.uri, - attachment: { - name: attachment.name, - mimeType: videoMimeType(attachment) ?? attachment.mimeType, - }, - signal: shareSignal, - sourceIdentifier, - }); - } finally { - releaseShare(); - } - }, - }; - } catch (cause) { - release(); - if (signal.aborted) return null; - throw new Error("This video is no longer available. Attach the file again.", { cause }); - } -} diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index ebc19d82d6dae291962f1a953f67a871c5a8057a..61b97ab6a86f8b5526367d6425bcc0378df95e3e 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround. ## Native media presentations -`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's -full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child +`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, +image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child and pass the stable identifier to the presentation. The registry keeps weak references to source views; recycled or compact composer thumbnails can register the same identifier. Identifiers must distinguish simultaneously visible attachments. -This source registration does not own playback and can be reused by a future image -viewer. Android uses a regular view. +The source registration does not own the preview. Android uses a regular view. On iOS, video previews mount `AVPlayerViewController` temporarily inside the 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 the player uses a standard modal presentation. Programmatic entry uses the same guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; if that selector is unavailable, the player also falls back to a standard modal. -Images retain their existing viewer. + +`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, +or a retained composer file. On iOS, Quick Look owns image and document layout, controls, +zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail +and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or +another image scroll view over that presentation: Quick Look coordinates its image gestures +with the return to the thumbnail. Missing sources use the standard transition, and Reduce +Motion disables animation. A pending programmatic Close waits until the current presentation +or cancelled dismissal has settled before starting another transition. + +The shared native presenter copies original bytes into its own temporary directory and +removes that copy after dismissal. Network downloads write to disk, and sharing never edits +the source attachment. Draft images use their stored upload data rather than a potentially +expired picker URI. No React Navigation route or custom transition animator is needed. + +The same viewer handles message images, markdown images, PDF attachments and links, +composer thumbnails, and workspace image previews. The workspace PDF web preview has an +Open PDF action for the native viewer. Android retains its image viewer and uses the +system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. Received videos open directly from their signed asset URL. AVKit handles buffering; the client does not download the entire file or show a separate opening overlay before diff --git a/docs/user/composer.md b/docs/user/composer.md index 62db0b073ffdf7d896c2abe3faf32a59c635ae61..4e251d5d6d583c6c856479829a0ecc56f8bb2e5e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and ** Videos use the server's file upload limit. You can also share photos, videos, and files into T3 Code from other apps through the system share sheet. Mobile uploads happen when the message sends, so queued messages keep their files until they deliver. Select a received file on mobile -to save it or open it in another app through the system share sheet. +to preview it or open the system share options. + +Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail +into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. +Use Share to save a copy or send it to another app. PDFs support page navigation and search. +PDF links in assistant responses open the same preview. On Android, images open in the image +viewer and PDFs open the system chooser. Select a video attachment before or after sending to play it. Web and desktop use the browser's 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 and shows **Attach again** next to it. Attach the file again or remove it, then send. On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into -the composer or paste them into a message. +the composer or paste them into a message. On iOS, selecting them from **Photo Library** also +converts them to JPEG. The 10 MB image limit applies to the converted photo. On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list