| author | |
| committer | |
| log | 31c1c5996f88e3acf1566adc11c9b51ac7561554 |
| tree | d313522332ad12f79b4016ea569883cb33392419 |
| parent | ef84bc9873a6c4565fbeb64dce3f552570e95a2d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>37 files changed, 2494 insertions(+), 213 deletions(-)
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+77| ... | ... | @@ -3,9 +3,43 @@ import Security |
| 3 | 3 | import UIKit |
| 4 | 4 | |
| 5 | 5 | public final class T3NativeControlsModule: Module { |
| 6 | private let presentationSources = T3PresentationSources() | |
| 7 | private var videoPresentation: T3NativeVideoPresentation? | |
| 8 | ||
| 6 | 9 | public func definition() -> ModuleDefinition { |
| 7 | 10 | Name("T3NativeControls") |
| 8 | 11 | |
| 12 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in | |
| 13 | try self.presentVideo( | |
| 14 | url: url, | |
| 15 | title: title, | |
| 16 | sourceIdentifier: sourceIdentifier, | |
| 17 | identifier: identifier, | |
| 18 | promise: promise | |
| 19 | ) | |
| 20 | }.runOnQueue(.main) | |
| 21 | ||
| 22 | AsyncFunction("dismissVideo") { (identifier: String) in | |
| 23 | self.dismissVideo(identifier: identifier) | |
| 24 | }.runOnQueue(.main) | |
| 25 | ||
| 26 | OnDestroy { | |
| 27 | let presentation = self.videoPresentation | |
| 28 | DispatchQueue.main.async { presentation?.dismiss() } | |
| 29 | } | |
| 30 | ||
| 31 | View(T3PresentationSourceView.self) { | |
| 32 | ViewName("PresentationSource") | |
| 33 | Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in | |
| 34 | view.sources = self.presentationSources | |
| 35 | view.identifier = identifier | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in | |
| 40 | try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) | |
| 41 | }.runOnQueue(.main) | |
| 42 | ||
| 9 | 43 | Function("getShowcasePairingUrl") { |
| 10 | 44 | let arguments = ProcessInfo.processInfo.arguments |
| 11 | 45 | guard |
| ... | ... | @@ -101,4 +135,47 @@ public final class T3NativeControlsModule: Module { |
| 101 | 135 | try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) |
| 102 | 136 | } |
| 103 | 137 | } |
| 138 | ||
| 139 | private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { | |
| 140 | let isPlayableURL = url.isFileURL | |
| 141 | ? FileManager.default.isReadableFile(atPath: url.path) | |
| 142 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) | |
| 143 | guard videoPresentation == nil, | |
| 144 | let presenter = appContext?.utilities?.currentViewController(), | |
| 145 | isPlayableURL | |
| 146 | else { | |
| 147 | throw NSError( | |
| 148 | domain: "T3NativeVideo", | |
| 149 | code: 2, | |
| 150 | userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] | |
| 151 | ) | |
| 152 | } | |
| 153 | let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in | |
| 154 | self?.videoPresentation = nil | |
| 155 | if let error { promise.reject(error) } else { promise.resolve(nil) } | |
| 156 | } | |
| 157 | videoPresentation = presentation | |
| 158 | presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) | |
| 159 | } | |
| 160 | ||
| 161 | private func dismissVideo(identifier: String) { | |
| 162 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } | |
| 163 | } | |
| 164 | ||
| 165 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { | |
| 166 | guard let presenter = appContext?.utilities?.currentViewController() else { | |
| 167 | throw NSError( | |
| 168 | domain: "T3NativePresentation", | |
| 169 | code: 2, | |
| 170 | userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] | |
| 171 | ) | |
| 172 | } | |
| 173 | try presentFileShare( | |
| 174 | url: url, | |
| 175 | title: title, | |
| 176 | source: presentationSources.view(for: sourceIdentifier), | |
| 177 | presenter: presenter, | |
| 178 | promise: promise | |
| 179 | ) | |
| 180 | } | |
| 104 | 181 | } |
apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import ExpoModulesCore | |
| 2 | import UIKit | |
| 3 | ||
| 4 | final class T3PresentationSources { | |
| 5 | private class Entry { | |
| 6 | weak var view: UIView? | |
| 7 | init(_ view: UIView) { self.view = view } | |
| 8 | } | |
| 9 | ||
| 10 | private var entries: [String: Entry] = [:] | |
| 11 | ||
| 12 | func register(_ view: UIView, identifier: String) { | |
| 13 | entries[identifier] = Entry(view) | |
| 14 | } | |
| 15 | ||
| 16 | func remove(_ view: UIView, identifier: String) { | |
| 17 | if entries[identifier]?.view == nil || entries[identifier]?.view === view { | |
| 18 | entries.removeValue(forKey: identifier) | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | func view(for identifier: String) -> UIView? { | |
| 23 | // Use the child bounds, not the wrapper's potentially stretched layout bounds. | |
| 24 | entries[identifier]?.view?.subviews.first | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | final class T3PresentationSourceView: ExpoView { | |
| 29 | weak var sources: T3PresentationSources? | |
| 30 | var identifier = "" { | |
| 31 | didSet { | |
| 32 | sources?.remove(self, identifier: oldValue) | |
| 33 | if !identifier.isEmpty { sources?.register(self, identifier: identifier) } | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | deinit { | |
| 38 | sources?.remove(self, identifier: identifier) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | func presentFileShare( | |
| 43 | url: URL, | |
| 44 | title: String, | |
| 45 | source: UIView?, | |
| 46 | presenter: UIViewController, | |
| 47 | promise: Promise | |
| 48 | ) throws { | |
| 49 | guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else { | |
| 50 | throw NSError( | |
| 51 | domain: "T3NativePresentation", | |
| 52 | code: 1, | |
| 53 | userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."] | |
| 54 | ) | |
| 55 | } | |
| 56 | ||
| 57 | guard let origin = source ?? presenter.view else { | |
| 58 | throw NSError( | |
| 59 | domain: "T3NativePresentation", | |
| 60 | code: 2, | |
| 61 | userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] | |
| 62 | ) | |
| 63 | } | |
| 64 | ||
| 65 | let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) | |
| 66 | activity.title = title | |
| 67 | activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle | |
| 68 | ?? presenter.traitCollection.userInterfaceStyle | |
| 69 | activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) } | |
| 70 | activity.modalPresentationStyle = .popover | |
| 71 | activity.popoverPresentationController?.sourceView = origin | |
| 72 | activity.popoverPresentationController?.sourceRect = source?.bounds | |
| 73 | ?? CGRect(x: origin.bounds.midX, y: origin.bounds.maxY, width: 0, height: 0) | |
| 74 | presenter.present(activity, animated: true) | |
| 75 | } |
apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | import AVKit | |
| 2 | import UIKit | |
| 3 | ||
| 4 | final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, | |
| 5 | UIAdaptivePresentationControllerDelegate { | |
| 6 | let identifier: String | |
| 7 | private let controller = AVPlayerViewController() | |
| 8 | private let completion: (Error?) -> Void | |
| 9 | private var itemObservation: NSKeyValueObservation? | |
| 10 | private var backgroundObserver: NSObjectProtocol? | |
| 11 | private var playbackError: Error? | |
| 12 | private var presented = false | |
| 13 | private var dismissRequested = false | |
| 14 | private var finished = false | |
| 15 | private struct AudioSessionConfiguration { | |
| 16 | let category: AVAudioSession.Category | |
| 17 | let mode: AVAudioSession.Mode | |
| 18 | let options: AVAudioSession.CategoryOptions | |
| 19 | ||
| 20 | init(_ session: AVAudioSession) { | |
| 21 | category = session.category | |
| 22 | mode = session.mode | |
| 23 | options = session.categoryOptions | |
| 24 | } | |
| 25 | } | |
| 26 | private var previousAudioSession: AudioSessionConfiguration? | |
| 27 | private weak var fullScreenController: UIViewController? | |
| 28 | private var embedded = false | |
| 29 | ||
| 30 | init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { | |
| 31 | self.identifier = identifier | |
| 32 | self.completion = completion | |
| 33 | super.init() | |
| 34 | ||
| 35 | let item = AVPlayerItem(url: url) | |
| 36 | let metadata = AVMutableMetadataItem() | |
| 37 | metadata.identifier = .commonIdentifierTitle | |
| 38 | metadata.value = title as NSString | |
| 39 | item.externalMetadata = [metadata] | |
| 40 | controller.player = AVPlayer(playerItem: item) | |
| 41 | controller.delegate = self | |
| 42 | controller.overrideUserInterfaceStyle = .dark | |
| 43 | controller.allowsPictureInPicturePlayback = false | |
| 44 | ||
| 45 | itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in | |
| 46 | guard item.status == .failed else { return } | |
| 47 | DispatchQueue.main.async { | |
| 48 | guard let self else { return } | |
| 49 | self.playbackError = item.error ?? NSError( | |
| 50 | domain: "T3NativeVideo", | |
| 51 | code: 1, | |
| 52 | userInfo: [NSLocalizedDescriptionKey: "This video couldn't be played on this device."] | |
| 53 | ) | |
| 54 | self.dismiss() | |
| 55 | } | |
| 56 | } | |
| 57 | backgroundObserver = NotificationCenter.default.addObserver( | |
| 58 | forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main | |
| 59 | ) { [weak self] _ in self?.controller.player?.pause() } | |
| 60 | } | |
| 61 | ||
| 62 | func present(from presenter: UIViewController, sources: T3PresentationSources, sourceIdentifier: String) { | |
| 63 | let audioSession = AVAudioSession.sharedInstance() | |
| 64 | previousAudioSession = AudioSessionConfiguration(audioSession) | |
| 65 | do { | |
| 66 | try audioSession.setCategory(.playback, mode: .moviePlayback) | |
| 67 | } catch { | |
| 68 | NSLog("T3 video audio session: %@", error.localizedDescription) | |
| 69 | } | |
| 70 | // AVKit exposes programmatic inline-to-full-screen entry through this selector. | |
| 71 | // This is the same guarded entry point used by expo-video's enterFullscreen(). | |
| 72 | let enterFullScreen = NSSelectorFromString("enterFullScreenAnimated:completionHandler:") | |
| 73 | if let source = sources.view(for: sourceIdentifier), source.window != nil, | |
| 74 | controller.responds(to: enterFullScreen) { | |
| 75 | // AVKit owns the transition from its inline view to full screen. Using a | |
| 76 | // separate UIKit zoom transition prevents its native Close action from exiting. | |
| 77 | var responder: UIResponder? = source | |
| 78 | while let current = responder, !(current is UIViewController) { responder = current.next } | |
| 79 | let parent = responder as? UIViewController ?? presenter | |
| 80 | embedded = true | |
| 81 | parent.addChild(controller) | |
| 82 | controller.view.frame = source.bounds | |
| 83 | controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] | |
| 84 | source.addSubview(controller.view) | |
| 85 | controller.didMove(toParent: parent) | |
| 86 | controller.view.layoutIfNeeded() | |
| 87 | controller.perform(enterFullScreen, with: true, with: nil) | |
| 88 | controller.player?.play() | |
| 89 | } else { | |
| 90 | presenter.present(controller, animated: true) { [self] in | |
| 91 | presented = true | |
| 92 | if dismissRequested { | |
| 93 | dismiss() | |
| 94 | } else if UIApplication.shared.applicationState == .active { | |
| 95 | controller.player?.play() | |
| 96 | } | |
| 97 | } | |
| 98 | controller.presentationController?.delegate = self | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | func dismiss() { | |
| 103 | dismissRequested = true | |
| 104 | guard !finished else { return } | |
| 105 | guard presented else { | |
| 106 | if embedded && fullScreenController == nil { finish() } | |
| 107 | return | |
| 108 | } | |
| 109 | (fullScreenController ?? controller).dismiss(animated: true) { [self] in finish() } | |
| 110 | } | |
| 111 | ||
| 112 | func playerViewController( | |
| 113 | _ playerViewController: AVPlayerViewController, | |
| 114 | willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator | |
| 115 | ) { | |
| 116 | fullScreenController = coordinator.viewController(forKey: .to) | |
| 117 | coordinator.animate(alongsideTransition: nil) { [weak self] context in | |
| 118 | guard let self else { return } | |
| 119 | if context.isCancelled { | |
| 120 | finish() | |
| 121 | } else { | |
| 122 | presented = true | |
| 123 | if dismissRequested { dismiss() } | |
| 124 | } | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | func playerViewController( | |
| 129 | _ playerViewController: AVPlayerViewController, | |
| 130 | willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator | |
| 131 | ) { | |
| 132 | coordinator.animate(alongsideTransition: nil) { [weak self] context in | |
| 133 | if !context.isCancelled { self?.finish() } | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { | |
| 138 | finish() | |
| 139 | } | |
| 140 | ||
| 141 | private func finish() { | |
| 142 | guard !finished else { return } | |
| 143 | finished = true | |
| 144 | controller.player?.pause() | |
| 145 | if embedded { | |
| 146 | controller.willMove(toParent: nil) | |
| 147 | controller.view.removeFromSuperview() | |
| 148 | controller.removeFromParent() | |
| 149 | } | |
| 150 | itemObservation = nil | |
| 151 | controller.player = nil | |
| 152 | if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } | |
| 153 | backgroundObserver = nil | |
| 154 | let audioSession = AVAudioSession.sharedInstance() | |
| 155 | if let previousAudioSession, audioSession.category == .playback, | |
| 156 | audioSession.mode == .moviePlayback, audioSession.categoryOptions.isEmpty { | |
| 157 | // AVPlayer owns activation. Deactivating the shared session here could | |
| 158 | // stop another player or recorder that was active before this preview. | |
| 159 | try? audioSession.setCategory( | |
| 160 | previousAudioSession.category, | |
| 161 | mode: previousAudioSession.mode, | |
| 162 | options: previousAudioSession.options | |
| 163 | ) | |
| 164 | } | |
| 165 | completion(playbackError) | |
| 166 | } | |
| 167 | } |
apps/mobile/package.json+2| ... | ... | @@ -85,6 +85,7 @@ |
| 85 | 85 | "expo-crypto": "~57.0.2", |
| 86 | 86 | "expo-dev-client": "~57.0.16", |
| 87 | 87 | "expo-device": "~57.0.1", |
| 88 | "expo-document-picker": "~57.0.1", | |
| 88 | 89 | "expo-file-system": "~57.0.6", |
| 89 | 90 | "expo-font": "~57.0.2", |
| 90 | 91 | "expo-glass-effect": "~57.0.1", |
| ... | ... | @@ -102,6 +103,7 @@ |
| 102 | 103 | "expo-sqlite": "~57.0.2", |
| 103 | 104 | "expo-symbols": "~57.0.2", |
| 104 | 105 | "expo-updates": "~57.0.19", |
| 106 | "expo-video": "~57.0.3", | |
| 105 | 107 | "expo-web-browser": "~57.0.2", |
| 106 | 108 | "expo-widgets": "~57.0.15", |
| 107 | 109 | "punycode": "^2.3.1", |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+141-34| ... | ... | @@ -1,8 +1,12 @@ |
| 1 | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | import { Image, Pressable, ScrollView, View } from "react-native"; | |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 3 | import { useEffect, useRef, useState } from "react"; | |
| 4 | import { Alert, Image, Pressable, ScrollView, View } from "react-native"; | |
| 3 | 5 | |
| 4 | 6 | import { AppText as Text } from "./AppText"; |
| 5 | import type { DraftComposerAttachment } from "../lib/composerImages"; | |
| 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; | |
| 9 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 6 | 10 | |
| 7 | 11 | export interface ComposerAttachmentStripProps { |
| 8 | 12 | /** Attachments to display. */ |
| ... | ... | @@ -11,6 +15,10 @@ export interface ComposerAttachmentStripProps { |
| 11 | 15 | readonly onRemove: (imageId: string) => void; |
| 12 | 16 | /** Called when the user taps on an image thumbnail to preview it. */ |
| 13 | 17 | readonly onPressImage?: (previewUri: string) => void; |
| 18 | readonly onPressVideo?: ( | |
| 19 | attachment: DraftComposerFileAttachment, | |
| 20 | sourceIdentifier: string, | |
| 21 | ) => void; | |
| 14 | 22 | /** Image thumbnail size in points. Defaults to 72. */ |
| 15 | 23 | readonly imageSize?: number; |
| 16 | 24 | /** Border radius of each image thumbnail. Defaults to 16. */ |
| ... | ... | @@ -19,6 +27,130 @@ export interface ComposerAttachmentStripProps { |
| 19 | 27 | readonly removeButtonPlacement?: "overlay" | "gutter"; |
| 20 | 28 | } |
| 21 | 29 | |
| 30 | export function ComposerAttachmentThumbnail(props: { | |
| 31 | readonly attachment: DraftComposerAttachment; | |
| 32 | readonly size: number; | |
| 33 | readonly borderRadius: number; | |
| 34 | readonly compact?: boolean; | |
| 35 | readonly onPressImage?: (previewUri: string) => void; | |
| 36 | readonly onPressVideo?: ( | |
| 37 | attachment: DraftComposerFileAttachment, | |
| 38 | sourceIdentifier: string, | |
| 39 | ) => void; | |
| 40 | }) { | |
| 41 | const { attachment } = props; | |
| 42 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | |
| 43 | if (attachment.type === "image") { | |
| 44 | return ( | |
| 45 | <Pressable | |
| 46 | onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined} | |
| 47 | > | |
| 48 | <Image | |
| 49 | source={{ uri: attachment.previewUri }} | |
| 50 | style={style} | |
| 51 | className="bg-subtle" | |
| 52 | resizeMode="cover" | |
| 53 | /> | |
| 54 | </Pressable> | |
| 55 | ); | |
| 56 | } | |
| 57 | const onPressVideo = props.onPressVideo; | |
| 58 | if (onPressVideo && videoMimeType(attachment) !== null) { | |
| 59 | return ( | |
| 60 | <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} /> | |
| 61 | ); | |
| 62 | } | |
| 63 | return ( | |
| 64 | <View | |
| 65 | className={ | |
| 66 | props.compact | |
| 67 | ? "items-center justify-center bg-subtle" | |
| 68 | : "items-center justify-center gap-1 bg-subtle px-2" | |
| 69 | } | |
| 70 | style={style} | |
| 71 | > | |
| 72 | <SymbolView | |
| 73 | name="doc.text" | |
| 74 | size={props.compact ? 15 : 22} | |
| 75 | tintColor="#a3a3a3" | |
| 76 | type="monochrome" | |
| 77 | /> | |
| 78 | {!props.compact ? ( | |
| 79 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 80 | {attachment.name} | |
| 81 | </Text> | |
| 82 | ) : null} | |
| 83 | </View> | |
| 84 | ); | |
| 85 | } | |
| 86 | ||
| 87 | function ComposerVideoAttachment(props: { | |
| 88 | readonly attachment: DraftComposerFileAttachment; | |
| 89 | readonly size: number; | |
| 90 | readonly borderRadius: number; | |
| 91 | readonly compact?: boolean; | |
| 92 | readonly onPressVideo: ( | |
| 93 | attachment: DraftComposerFileAttachment, | |
| 94 | sourceIdentifier: string, | |
| 95 | ) => void; | |
| 96 | }) { | |
| 97 | const { attachment } = props; | |
| 98 | const sourceIdentifier = `draft:${attachment.id}`; | |
| 99 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | |
| 100 | const shareRef = useRef<AbortController | null>(null); | |
| 101 | const [sharing, setSharing] = useState(false); | |
| 102 | useEffect( | |
| 103 | () => () => { | |
| 104 | shareRef.current?.abort(); | |
| 105 | shareRef.current = null; | |
| 106 | }, | |
| 107 | [], | |
| 108 | ); | |
| 109 | ||
| 110 | const onShare = () => { | |
| 111 | if (shareRef.current) return; | |
| 112 | const controller = new AbortController(); | |
| 113 | shareRef.current = controller; | |
| 114 | setSharing(true); | |
| 115 | void (async () => { | |
| 116 | const preview = await loadLocalVideoPreview(attachment, controller.signal); | |
| 117 | if (!preview) return; | |
| 118 | try { | |
| 119 | await preview.share(controller.signal, sourceIdentifier); | |
| 120 | } finally { | |
| 121 | preview.dispose(); | |
| 122 | } | |
| 123 | })() | |
| 124 | .catch((error: unknown) => { | |
| 125 | if (!controller.signal.aborted) { | |
| 126 | Alert.alert( | |
| 127 | "Could not share video", | |
| 128 | error instanceof Error ? error.message : "Try again.", | |
| 129 | ); | |
| 130 | } | |
| 131 | }) | |
| 132 | .finally(() => { | |
| 133 | if (shareRef.current === controller) { | |
| 134 | shareRef.current = null; | |
| 135 | setSharing(false); | |
| 136 | } | |
| 137 | }); | |
| 138 | }; | |
| 139 | ||
| 140 | return ( | |
| 141 | <VideoAttachmentTile | |
| 142 | name={attachment.name} | |
| 143 | sourceIdentifier={sourceIdentifier} | |
| 144 | thumbnailSource={attachment} | |
| 145 | compact={props.compact} | |
| 146 | onPress={() => props.onPressVideo(attachment, sourceIdentifier)} | |
| 147 | onShare={onShare} | |
| 148 | disabled={sharing} | |
| 149 | style={style} | |
| 150 | /> | |
| 151 | ); | |
| 152 | } | |
| 153 | ||
| 22 | 154 | /** |
| 23 | 155 | * Attachment thumbnails used by the thread composer and the new-task draft screen. |
| 24 | 156 | */ |
| ... | ... | @@ -49,38 +181,13 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 49 | 181 | paddingRight: removeButtonGutter, |
| 50 | 182 | }} |
| 51 | 183 | > |
| 52 | {attachment.type === "image" ? ( | |
| 53 | <Pressable | |
| 54 | onPress={ | |
| 55 | props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined | |
| 56 | } | |
| 57 | > | |
| 58 | <Image | |
| 59 | source={{ uri: attachment.previewUri }} | |
| 60 | style={{ | |
| 61 | width: size, | |
| 62 | height: size, | |
| 63 | borderRadius: radius, | |
| 64 | }} | |
| 65 | className="bg-subtle" | |
| 66 | resizeMode="cover" | |
| 67 | /> | |
| 68 | </Pressable> | |
| 69 | ) : ( | |
| 70 | <View | |
| 71 | className="items-center justify-center gap-1 bg-subtle px-2" | |
| 72 | style={{ | |
| 73 | width: size, | |
| 74 | height: size, | |
| 75 | borderRadius: radius, | |
| 76 | }} | |
| 77 | > | |
| 78 | <SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" /> | |
| 79 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 80 | {attachment.name} | |
| 81 | </Text> | |
| 82 | </View> | |
| 83 | )} | |
| 184 | <ComposerAttachmentThumbnail | |
| 185 | attachment={attachment} | |
| 186 | size={size} | |
| 187 | borderRadius={radius} | |
| 188 | onPressImage={props.onPressImage} | |
| 189 | onPressVideo={props.onPressVideo} | |
| 190 | /> | |
| 84 | 191 | <Pressable |
| 85 | 192 | className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55" |
| 86 | 193 | style={{ |
apps/mobile/src/components/NativePresentation.ios.tsx created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | import { requireNativeView } from "expo"; | |
| 2 | import type { ComponentType } from "react"; | |
| 3 | import type { PresentationSourceProps } from "./NativePresentation"; | |
| 4 | ||
| 5 | const NativeSource: ComponentType<PresentationSourceProps> = requireNativeView( | |
| 6 | "T3NativeControls", | |
| 7 | "PresentationSource", | |
| 8 | ); | |
| 9 | ||
| 10 | export function PresentationSource(props: PresentationSourceProps) { | |
| 11 | return <NativeSource {...props} collapsableChildren={false} />; | |
| 12 | } |
apps/mobile/src/components/NativePresentation.tsx created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import type { ReactElement } from "react"; | |
| 2 | import { View, type ViewProps } from "react-native"; | |
| 3 | ||
| 4 | export interface PresentationSourceProps extends ViewProps { | |
| 5 | readonly children: ReactElement; | |
| 6 | /** Stable across remounts so dismissal can find a recycled attachment thumbnail. */ | |
| 7 | readonly identifier: string; | |
| 8 | } | |
| 9 | ||
| 10 | /** Registers the view as an iOS zoom or share-sheet origin. */ | |
| 11 | export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { | |
| 12 | return <View {...props} />; | |
| 13 | } |
apps/mobile/src/components/VideoAttachmentMenu.tsx created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import type { ReactElement } from "react"; | |
| 2 | import { Platform, type PressableProps } from "react-native"; | |
| 3 | ||
| 4 | import { ControlPillMenu } from "./ControlPill"; | |
| 5 | import { PresentationSource } from "./NativePresentation"; | |
| 6 | ||
| 7 | export function VideoAttachmentMenu(props: { | |
| 8 | readonly sourceIdentifier: string; | |
| 9 | readonly onOpen: () => void; | |
| 10 | readonly onShare?: () => void; | |
| 11 | readonly disabled?: boolean; | |
| 12 | readonly children: ReactElement<PressableProps>; | |
| 13 | }) { | |
| 14 | return ( | |
| 15 | <PresentationSource | |
| 16 | identifier={props.sourceIdentifier} | |
| 17 | accessible={Platform.OS === "ios"} | |
| 18 | accessibilityRole="button" | |
| 19 | accessibilityLabel={props.children.props.accessibilityLabel} | |
| 20 | accessibilityHint={props.children.props.accessibilityHint} | |
| 21 | accessibilityState={{ disabled: props.disabled ?? false }} | |
| 22 | onAccessibilityTap={() => { | |
| 23 | if (!props.disabled) props.onOpen(); | |
| 24 | }} | |
| 25 | accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} | |
| 26 | onAccessibilityAction={({ nativeEvent }) => { | |
| 27 | if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); | |
| 28 | }} | |
| 29 | > | |
| 30 | {Platform.OS === "ios" && props.onShare ? ( | |
| 31 | <ControlPillMenu | |
| 32 | shouldOpenOnLongPress | |
| 33 | style={{ alignSelf: "flex-start" }} | |
| 34 | actions={[ | |
| 35 | { | |
| 36 | id: "share", | |
| 37 | title: "Save or share video", | |
| 38 | image: "square.and.arrow.up", | |
| 39 | attributes: { disabled: props.disabled ?? false }, | |
| 40 | }, | |
| 41 | ]} | |
| 42 | onPressAction={({ nativeEvent }) => { | |
| 43 | if (nativeEvent.event === "share") props.onShare?.(); | |
| 44 | }} | |
| 45 | > | |
| 46 | {props.children} | |
| 47 | </ControlPillMenu> | |
| 48 | ) : ( | |
| 49 | props.children | |
| 50 | )} | |
| 51 | </PresentationSource> | |
| 52 | ); | |
| 53 | } |
apps/mobile/src/components/VideoAttachmentTile.tsx created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; | |
| 2 | ||
| 3 | import { cn } from "../lib/cn"; | |
| 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 5 | import { SymbolView } from "./AppSymbol"; | |
| 6 | import { AppText } from "./AppText"; | |
| 7 | import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; | |
| 8 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; | |
| 9 | ||
| 10 | export function VideoAttachmentTile(props: { | |
| 11 | readonly name: string; | |
| 12 | readonly sourceIdentifier: string; | |
| 13 | readonly thumbnailSource: string | DraftComposerFileAttachment | null; | |
| 14 | readonly compact?: boolean; | |
| 15 | readonly onPress: (sourceIdentifier: string) => void; | |
| 16 | readonly onShare?: () => void; | |
| 17 | readonly disabled?: boolean; | |
| 18 | readonly className?: string; | |
| 19 | readonly style?: StyleProp<ViewStyle>; | |
| 20 | }) { | |
| 21 | return ( | |
| 22 | <VideoAttachmentMenu | |
| 23 | sourceIdentifier={props.sourceIdentifier} | |
| 24 | onOpen={() => props.onPress(props.sourceIdentifier)} | |
| 25 | onShare={props.onShare} | |
| 26 | disabled={props.disabled} | |
| 27 | > | |
| 28 | <Pressable | |
| 29 | accessibilityRole="button" | |
| 30 | accessibilityLabel={`Play ${props.name}`} | |
| 31 | accessibilityHint={ | |
| 32 | Platform.OS === "ios" && props.onShare | |
| 33 | ? "Touch and hold for save and share options" | |
| 34 | : undefined | |
| 35 | } | |
| 36 | accessibilityState={{ disabled: props.disabled ?? false }} | |
| 37 | disabled={props.disabled} | |
| 38 | onPress={() => props.onPress(props.sourceIdentifier)} | |
| 39 | className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} | |
| 40 | style={props.style} | |
| 41 | > | |
| 42 | <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} /> | |
| 43 | <View | |
| 44 | className={cn( | |
| 45 | "items-center justify-center rounded-full bg-black/45", | |
| 46 | props.compact ? "size-6" : "size-12", | |
| 47 | )} | |
| 48 | > | |
| 49 | <SymbolView | |
| 50 | name="play" | |
| 51 | size={props.compact ? 15 : 24} | |
| 52 | tintColor="#ffffff" | |
| 53 | type="monochrome" | |
| 54 | /> | |
| 55 | </View> | |
| 56 | {!props.compact ? ( | |
| 57 | <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5"> | |
| 58 | <AppText className="text-center text-xs text-white" numberOfLines={1}> | |
| 59 | {props.name} | |
| 60 | </AppText> | |
| 61 | </View> | |
| 62 | ) : null} | |
| 63 | </Pressable> | |
| 64 | </VideoAttachmentMenu> | |
| 65 | ); | |
| 66 | } |
apps/mobile/src/components/VideoPreviewModal.ios.tsx created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 3 | import { requireNativeModule } from "expo"; | |
| 4 | import { useEffect, useEffectEvent, useId, useState } from "react"; | |
| 5 | import { Alert, Keyboard } from "react-native"; | |
| 6 | ||
| 7 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 8 | import { useAssetUrlState } from "../state/assets"; | |
| 9 | import { usePreparedConnection } from "../state/session"; | |
| 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; | |
| 11 | ||
| 12 | export type { VideoPreviewSource } from "./VideoPreviewModal"; | |
| 13 | ||
| 14 | const NativeControls = requireNativeModule<{ | |
| 15 | presentVideo( | |
| 16 | uri: string, | |
| 17 | title: string, | |
| 18 | sourceIdentifier: string, | |
| 19 | identifier: string, | |
| 20 | ): Promise<void>; | |
| 21 | dismissVideo(identifier: string): Promise<void>; | |
| 22 | }>("T3NativeControls"); | |
| 23 | ||
| 24 | function NativeVideoPreview(props: { | |
| 25 | readonly source: VideoPreviewSource; | |
| 26 | readonly onRequestClose: () => void; | |
| 27 | }) { | |
| 28 | const { source } = props; | |
| 29 | const { attachment } = source; | |
| 30 | const identifier = useId(); | |
| 31 | const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 32 | const environmentId = source.type === "remote" ? source.environmentId : null; | |
| 33 | const preparedConnection = usePreparedConnection(environmentId); | |
| 34 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | |
| 35 | const assetUrl = useAssetUrlState( | |
| 36 | environmentId, | |
| 37 | source.type === "remote" | |
| 38 | ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } | |
| 39 | : null, | |
| 40 | ); | |
| 41 | const [playbackUrl, setPlaybackUrl] = useState<string | null>(() => | |
| 42 | assetUrl._tag === "Success" ? assetUrl.url : null, | |
| 43 | ); | |
| 44 | const loadError = | |
| 45 | source.type === "remote" && playbackUrl === null | |
| 46 | ? preparedConnection._tag === "None" | |
| 47 | ? "Reconnect to this environment and open the video again." | |
| 48 | : assetUrl._tag === "Failure" | |
| 49 | ? "Could not load this video. Check the connection and try again." | |
| 50 | : null | |
| 51 | : null; | |
| 52 | ||
| 53 | useEffect(() => Keyboard.dismiss(), []); | |
| 54 | useEffect(() => { | |
| 55 | if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); | |
| 56 | }, [playbackUrl, assetUrl]); | |
| 57 | useEffect(() => { | |
| 58 | if (!loadError) return; | |
| 59 | Alert.alert("Could not open video", loadError); | |
| 60 | onRequestClose(); | |
| 61 | }, [loadError]); | |
| 62 | ||
| 63 | useEffect(() => { | |
| 64 | if (source.type === "remote" && playbackUrl === null) return; | |
| 65 | const controller = new AbortController(); | |
| 66 | let ready = false; | |
| 67 | void (async () => { | |
| 68 | const file = | |
| 69 | source.type === "local" | |
| 70 | ? await loadLocalVideoPreview(source.attachment, controller.signal) | |
| 71 | : null; | |
| 72 | if (source.type === "local" && !file) return; | |
| 73 | try { | |
| 74 | if (controller.signal.aborted) return; | |
| 75 | ready = true; | |
| 76 | await NativeControls.presentVideo( | |
| 77 | file?.uri ?? playbackUrl!, | |
| 78 | attachment.name, | |
| 79 | source.sourceIdentifier ?? "", | |
| 80 | identifier, | |
| 81 | ); | |
| 82 | if (!controller.signal.aborted) onRequestClose(); | |
| 83 | } finally { | |
| 84 | // Native completion follows dismissal, so local playback keeps its file lease. | |
| 85 | file?.dispose(); | |
| 86 | } | |
| 87 | })().catch((error: unknown) => { | |
| 88 | if (controller.signal.aborted) return; | |
| 89 | Alert.alert( | |
| 90 | "Could not open video", | |
| 91 | ready | |
| 92 | ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." | |
| 93 | : error instanceof Error | |
| 94 | ? error.message | |
| 95 | : "Could not load this video.", | |
| 96 | ); | |
| 97 | onRequestClose(); | |
| 98 | }); | |
| 99 | return () => { | |
| 100 | controller.abort(); | |
| 101 | void NativeControls.dismissVideo(identifier).catch(() => undefined); | |
| 102 | }; | |
| 103 | }, [source, attachment.name, playbackUrl, identifier]); | |
| 104 | ||
| 105 | return null; | |
| 106 | } | |
| 107 | ||
| 108 | export function VideoPreviewModal(props: { | |
| 109 | readonly source: VideoPreviewSource | null; | |
| 110 | readonly onRequestClose: () => void; | |
| 111 | }) { | |
| 112 | const isFocused = useIsFocused(); | |
| 113 | const hasSource = props.source !== null; | |
| 114 | const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 115 | useEffect(() => { | |
| 116 | if (!isFocused && hasSource) onRequestClose(); | |
| 117 | }, [isFocused, hasSource]); | |
| 118 | ||
| 119 | if (!props.source || !isFocused) return null; | |
| 120 | return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />; | |
| 121 | } |
apps/mobile/src/components/VideoPreviewModal.tsx created+258| ... | ... | @@ -0,0 +1,258 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | |
| 2 | import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 4 | import { useEvent } from "expo"; | |
| 5 | import { useVideoPlayer, VideoView } from "expo-video"; | |
| 6 | import { useEffect, useRef, useState } from "react"; | |
| 7 | import { | |
| 8 | ActivityIndicator, | |
| 9 | AppState, | |
| 10 | Keyboard, | |
| 11 | Modal, | |
| 12 | Pressable, | |
| 13 | StyleSheet, | |
| 14 | View, | |
| 15 | } from "react-native"; | |
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 17 | ||
| 18 | import { | |
| 19 | downloadAttachmentForPreview, | |
| 20 | type AttachmentPreviewFile, | |
| 21 | } from "../lib/attachmentDownload"; | |
| 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 23 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 24 | import { useAssetUrlState } from "../state/assets"; | |
| 25 | import { usePreparedConnection } from "../state/session"; | |
| 26 | import { SymbolView } from "./AppSymbol"; | |
| 27 | import { AppText } from "./AppText"; | |
| 28 | ||
| 29 | export type VideoPreviewSource = ( | |
| 30 | | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } | |
| 31 | | { | |
| 32 | readonly type: "remote"; | |
| 33 | readonly environmentId: EnvironmentId; | |
| 34 | readonly attachment: ChatFileAttachment; | |
| 35 | } | |
| 36 | ) & { readonly sourceIdentifier?: string }; | |
| 37 | ||
| 38 | function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { | |
| 39 | const player = useVideoPlayer(props.file.uri, (player) => { | |
| 40 | player.staysActiveInBackground = false; | |
| 41 | if (AppState.currentState === "active") player.play(); | |
| 42 | }); | |
| 43 | const { status } = useEvent(player, "statusChange", { status: player.status }); | |
| 44 | const shareControllerRef = useRef<AbortController | null>(null); | |
| 45 | const [sharing, setSharing] = useState(false); | |
| 46 | const [shareError, setShareError] = useState<string | null>(null); | |
| 47 | ||
| 48 | useEffect( | |
| 49 | () => () => { | |
| 50 | shareControllerRef.current?.abort(); | |
| 51 | shareControllerRef.current = null; | |
| 52 | }, | |
| 53 | [], | |
| 54 | ); | |
| 55 | ||
| 56 | const onShare = () => { | |
| 57 | if (shareControllerRef.current) return; | |
| 58 | player.pause(); | |
| 59 | const controller = new AbortController(); | |
| 60 | shareControllerRef.current = controller; | |
| 61 | setSharing(true); | |
| 62 | setShareError(null); | |
| 63 | void props.file | |
| 64 | .share(controller.signal) | |
| 65 | .catch((error: unknown) => { | |
| 66 | if (!controller.signal.aborted) { | |
| 67 | setShareError(error instanceof Error ? error.message : "Could not share this video."); | |
| 68 | } | |
| 69 | }) | |
| 70 | .finally(() => { | |
| 71 | if (shareControllerRef.current === controller) { | |
| 72 | shareControllerRef.current = null; | |
| 73 | setSharing(false); | |
| 74 | } | |
| 75 | }); | |
| 76 | }; | |
| 77 | ||
| 78 | return ( | |
| 79 | <> | |
| 80 | <View className="flex-1 items-center justify-center"> | |
| 81 | {status === "error" ? ( | |
| 82 | <AppText className="px-6 text-center text-base text-white/80"> | |
| 83 | This video couldn't be played on this device. You can save or share the original file. | |
| 84 | </AppText> | |
| 85 | ) : ( | |
| 86 | <> | |
| 87 | <VideoView | |
| 88 | player={player} | |
| 89 | style={StyleSheet.absoluteFill} | |
| 90 | nativeControls | |
| 91 | contentFit="contain" | |
| 92 | fullscreenOptions={{ enable: true }} | |
| 93 | allowsPictureInPicture={false} | |
| 94 | /> | |
| 95 | {status === "loading" ? ( | |
| 96 | <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> | |
| 97 | ) : null} | |
| 98 | </> | |
| 99 | )} | |
| 100 | </View> | |
| 101 | <Pressable | |
| 102 | accessibilityRole="button" | |
| 103 | accessibilityLabel="Save or share video" | |
| 104 | accessibilityState={{ disabled: sharing, busy: sharing }} | |
| 105 | disabled={sharing} | |
| 106 | onPress={onShare} | |
| 107 | className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4" | |
| 108 | > | |
| 109 | <AppText className="font-t3-medium text-base text-white"> | |
| 110 | {sharing ? "Opening share sheet..." : "Save or share video"} | |
| 111 | </AppText> | |
| 112 | </Pressable> | |
| 113 | {shareError ? ( | |
| 114 | <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80"> | |
| 115 | {shareError} | |
| 116 | </AppText> | |
| 117 | ) : null} | |
| 118 | </> | |
| 119 | ); | |
| 120 | } | |
| 121 | ||
| 122 | function OpenVideoPreviewModal(props: { | |
| 123 | readonly source: VideoPreviewSource; | |
| 124 | readonly onRequestClose: () => void; | |
| 125 | }) { | |
| 126 | const { source } = props; | |
| 127 | const { attachment } = source; | |
| 128 | const insets = useSafeAreaInsets(); | |
| 129 | const environmentId = source.type === "remote" ? source.environmentId : null; | |
| 130 | const preparedConnection = usePreparedConnection(environmentId); | |
| 131 | const fileUri = source.type === "local" ? source.attachment.fileUri : null; | |
| 132 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | |
| 133 | const assetUrl = useAssetUrlState( | |
| 134 | environmentId, | |
| 135 | source.type === "remote" | |
| 136 | ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } | |
| 137 | : null, | |
| 138 | ); | |
| 139 | const [downloadUrl, setDownloadUrl] = useState<string | null>(null); | |
| 140 | const [file, setFile] = useState<AttachmentPreviewFile | null>(null); | |
| 141 | const [failure, setFailure] = useState<string | null>(null); | |
| 142 | ||
| 143 | useEffect(() => Keyboard.dismiss(), []); | |
| 144 | useEffect(() => { | |
| 145 | if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { | |
| 146 | setDownloadUrl(assetUrl.url); | |
| 147 | } | |
| 148 | }, [environmentId, downloadUrl, assetUrl]); | |
| 149 | ||
| 150 | useEffect(() => { | |
| 151 | if (source.type === "remote" && downloadUrl === null) return; | |
| 152 | const controller = new AbortController(); | |
| 153 | let preview: AttachmentPreviewFile | null = null; | |
| 154 | setFile(null); | |
| 155 | setFailure(null); | |
| 156 | const loading = | |
| 157 | source.type === "local" | |
| 158 | ? loadLocalVideoPreview(source.attachment, controller.signal) | |
| 159 | : downloadAttachmentForPreview({ | |
| 160 | url: downloadUrl!, | |
| 161 | attachment: { name: attachment.name, mimeType }, | |
| 162 | signal: controller.signal, | |
| 163 | }); | |
| 164 | void loading.then( | |
| 165 | (loaded) => { | |
| 166 | if (controller.signal.aborted) { | |
| 167 | loaded?.dispose(); | |
| 168 | return; | |
| 169 | } | |
| 170 | preview = loaded; | |
| 171 | setFile(loaded); | |
| 172 | }, | |
| 173 | (error: unknown) => { | |
| 174 | if (!controller.signal.aborted) { | |
| 175 | setFailure(error instanceof Error ? error.message : "Could not load this video."); | |
| 176 | } | |
| 177 | }, | |
| 178 | ); | |
| 179 | return () => { | |
| 180 | controller.abort(); | |
| 181 | preview?.dispose(); | |
| 182 | }; | |
| 183 | }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); | |
| 184 | ||
| 185 | const loadError = | |
| 186 | failure ?? | |
| 187 | (environmentId !== null && downloadUrl === null | |
| 188 | ? preparedConnection._tag === "None" | |
| 189 | ? "This environment is disconnected. Reconnect and open the video again." | |
| 190 | : assetUrl._tag === "Failure" | |
| 191 | ? "Could not load this video. Check the connection to this environment and try again." | |
| 192 | : null | |
| 193 | : null); | |
| 194 | ||
| 195 | return ( | |
| 196 | <Modal | |
| 197 | visible | |
| 198 | animationType="fade" | |
| 199 | presentationStyle="fullScreen" | |
| 200 | onRequestClose={props.onRequestClose} | |
| 201 | statusBarTranslucent | |
| 202 | navigationBarTranslucent | |
| 203 | > | |
| 204 | <View | |
| 205 | className="flex-1 bg-black" | |
| 206 | style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} | |
| 207 | > | |
| 208 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> | |
| 209 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> | |
| 210 | {attachment.name} | |
| 211 | </AppText> | |
| 212 | <Pressable | |
| 213 | accessibilityRole="button" | |
| 214 | accessibilityLabel="Close video" | |
| 215 | onPress={props.onRequestClose} | |
| 216 | className="size-12 items-center justify-center" | |
| 217 | > | |
| 218 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> | |
| 219 | </Pressable> | |
| 220 | </View> | |
| 221 | {file ? ( | |
| 222 | <VideoPlayback key={file.uri} file={file} /> | |
| 223 | ) : ( | |
| 224 | <View className="flex-1 items-center justify-center gap-3 px-6"> | |
| 225 | {loadError ? ( | |
| 226 | <AppText accessibilityRole="alert" className="text-center text-base text-white/80"> | |
| 227 | {loadError} | |
| 228 | </AppText> | |
| 229 | ) : ( | |
| 230 | <> | |
| 231 | <ActivityIndicator color="#ffffff" /> | |
| 232 | <AppText className="text-sm text-white/80">Loading video...</AppText> | |
| 233 | </> | |
| 234 | )} | |
| 235 | </View> | |
| 236 | )} | |
| 237 | </View> | |
| 238 | </Modal> | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | export function VideoPreviewModal(props: { | |
| 243 | readonly source: VideoPreviewSource | null; | |
| 244 | readonly onRequestClose: () => void; | |
| 245 | }) { | |
| 246 | const isFocused = useIsFocused(); | |
| 247 | const hasSource = props.source !== null; | |
| 248 | useEffect(() => { | |
| 249 | if (!isFocused && hasSource) props.onRequestClose(); | |
| 250 | }, [isFocused, hasSource, props.onRequestClose]); | |
| 251 | const { source } = props; | |
| 252 | if (source === null || !isFocused) return null; | |
| 253 | const key = | |
| 254 | source.type === "local" | |
| 255 | ? `local:${source.attachment.id}:${source.attachment.fileUri}` | |
| 256 | : `remote:${source.environmentId}:${source.attachment.id}`; | |
| 257 | return <OpenVideoPreviewModal key={key} source={source} onRequestClose={props.onRequestClose} />; | |
| 258 | } |
apps/mobile/src/components/VideoThumbnailImage.tsx created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | import { Image } from "expo-image"; | |
| 2 | import { useIsFocused } from "@react-navigation/native"; | |
| 3 | import type { VideoThumbnail } from "expo-video"; | |
| 4 | import { useEffect, useState } from "react"; | |
| 5 | import { StyleSheet } from "react-native"; | |
| 6 | ||
| 7 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 8 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 9 | import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; | |
| 10 | ||
| 11 | export function VideoThumbnailImage(props: { | |
| 12 | readonly cacheKey: string; | |
| 13 | readonly source: string | DraftComposerFileAttachment | null; | |
| 14 | }) { | |
| 15 | const { cacheKey, source } = props; | |
| 16 | const isFocused = useIsFocused(); | |
| 17 | const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); | |
| 18 | const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); | |
| 19 | ||
| 20 | useEffect(() => { | |
| 21 | if (!source || !isFocused) return; | |
| 22 | const controller = new AbortController(); | |
| 23 | void loadVideoThumbnail( | |
| 24 | cacheKey, | |
| 25 | async (signal) => | |
| 26 | typeof source === "string" | |
| 27 | ? { uri: source, dispose: () => undefined } | |
| 28 | : loadLocalVideoPreview(source, signal), | |
| 29 | controller.signal, | |
| 30 | ).then((thumbnail) => { | |
| 31 | if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); | |
| 32 | }); | |
| 33 | return () => controller.abort(); | |
| 34 | }, [cacheKey, source, isFocused]); | |
| 35 | ||
| 36 | return thumbnail ? ( | |
| 37 | <Image | |
| 38 | source={thumbnail} | |
| 39 | style={StyleSheet.absoluteFill} | |
| 40 | contentFit="cover" | |
| 41 | recyclingKey={cacheKey} | |
| 42 | accessible={false} | |
| 43 | /> | |
| 44 | ) : null; | |
| 45 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23| ... | ... | @@ -35,6 +35,7 @@ import { |
| 35 | 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 38 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 38 | 39 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 39 | 40 | import { SymbolView } from "../../components/AppSymbol"; |
| 40 | 41 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -60,6 +61,7 @@ import { |
| 60 | 61 | convertPastedImagesToAttachments, |
| 61 | 62 | pickComposerFiles, |
| 62 | 63 | pickComposerMedia, |
| 64 | type DraftComposerFileAttachment, | |
| 63 | 65 | } from "../../lib/composerImages"; |
| 64 | 66 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 65 | 67 | import { |
| ... | ... | @@ -164,6 +166,23 @@ export function NewTaskDraftScreen(props: { |
| 164 | 166 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 165 | 167 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 166 | 168 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 169 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | |
| 170 | const wasFocusedBeforeVideoRef = useRef(false); | |
| 171 | const openVideoPreview = useCallback( | |
| 172 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { | |
| 173 | wasFocusedBeforeVideoRef.current = isComposerFocused; | |
| 174 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); | |
| 175 | }, | |
| 176 | [isComposerFocused], | |
| 177 | ); | |
| 178 | const closeVideoPreview = useCallback(() => { | |
| 179 | setPreviewVideo(null); | |
| 180 | if (wasFocusedBeforeVideoRef.current) { | |
| 181 | setTimeout(() => { | |
| 182 | if (navigation.isFocused()) promptInputRef.current?.focus(); | |
| 183 | }, 100); | |
| 184 | } | |
| 185 | }, [navigation]); | |
| 167 | 186 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 168 | 187 | editorRef: promptInputRef, |
| 169 | 188 | isEditorFocused: isComposerFocused, |
| ... | ... | @@ -1193,6 +1212,9 @@ export function NewTaskDraftScreen(props: { |
| 1193 | 1212 | ? () => undefined |
| 1194 | 1213 | : flow.removeAttachment |
| 1195 | 1214 | } |
| 1215 | onPressVideo={ | |
| 1216 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview | |
| 1217 | } | |
| 1196 | 1218 | /> |
| 1197 | 1219 | </View> |
| 1198 | 1220 | ) : null} |
| ... | ... | @@ -1296,6 +1318,7 @@ export function NewTaskDraftScreen(props: { |
| 1296 | 1318 | </ComposerDictationToolbar> |
| 1297 | 1319 | </Animated.View> |
| 1298 | 1320 | </ComposerSurface> |
| 1321 | <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} /> | |
| 1299 | 1322 | </View> |
| 1300 | 1323 | ); |
| 1301 | 1324 |
apps/mobile/src/features/threads/ThreadComposer.tsx+39-27| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | useState, |
| 20 | 20 | type RefObject, |
| 21 | 21 | } from "react"; |
| 22 | import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native"; | |
| 22 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; | |
| 23 | 23 | import ImageViewing from "react-native-image-viewing"; |
| 24 | 24 | import Animated, { |
| 25 | 25 | FadeIn, |
| ... | ... | @@ -37,9 +37,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re |
| 37 | 37 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 38 | 38 | |
| 39 | 39 | import { AppText as Text } from "../../components/AppText"; |
| 40 | import { SymbolView } from "../../components/AppSymbol"; | |
| 41 | 40 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 42 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | |
| 41 | import { | |
| 42 | ComposerAttachmentStrip, | |
| 43 | ComposerAttachmentThumbnail, | |
| 44 | } from "../../components/ComposerAttachmentStrip"; | |
| 45 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 43 | 46 | import { GlassSurface } from "../../components/GlassSurface"; |
| 44 | 47 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 45 | 48 | import { |
| ... | ... | @@ -48,7 +51,10 @@ import { |
| 48 | 51 | ComposerToolbarRow, |
| 49 | 52 | } from "../../components/ComposerToolbar"; |
| 50 | 53 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 51 | import type { DraftComposerAttachment } from "../../lib/composerImages"; | |
| 54 | import type { | |
| 55 | DraftComposerAttachment, | |
| 56 | DraftComposerFileAttachment, | |
| 57 | } from "../../lib/composerImages"; | |
| 52 | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 53 | 59 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 54 | 60 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| ... | ... | @@ -306,6 +312,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 306 | 312 | const { onExpandedChange } = props; |
| 307 | 313 | |
| 308 | 314 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | |
| 309 | 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 310 | 317 | const showStopAction = |
| 311 | 318 | !hasContent && |
| ... | ... | @@ -368,6 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 368 | 375 | const onPressImage = useCallback( |
| 369 | 376 | (uri: string) => { |
| 370 | 377 | wasExpandedBeforePreviewRef.current = isFocused; |
| 378 | setPreviewVideo(null); | |
| 371 | 379 | setPreviewImageUri(uri); |
| 372 | 380 | }, |
| 373 | 381 | [isFocused], |
| ... | ... | @@ -375,10 +383,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 375 | 383 | |
| 376 | 384 | const closePreview = useCallback(() => { |
| 377 | 385 | setPreviewImageUri(null); |
| 386 | setPreviewVideo(null); | |
| 378 | 387 | if (wasExpandedBeforePreviewRef.current) { |
| 379 | setTimeout(() => inputRef.current?.focus(), 100); | |
| 388 | setTimeout(() => { | |
| 389 | if (navigation.isFocused()) inputRef.current?.focus(); | |
| 390 | }, 100); | |
| 380 | 391 | } |
| 381 | }, [inputRef]); | |
| 392 | }, [inputRef, navigation]); | |
| 393 | ||
| 394 | const onPressVideo = useCallback( | |
| 395 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { | |
| 396 | wasExpandedBeforePreviewRef.current = isFocused; | |
| 397 | setPreviewImageUri(null); | |
| 398 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); | |
| 399 | }, | |
| 400 | [isFocused], | |
| 401 | ); | |
| 382 | 402 | |
| 383 | 403 | const onEditorFocusChange = props.onEditorFocusChange; |
| 384 | 404 | const handleFocus = useCallback(() => { |
| ... | ... | @@ -600,6 +620,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 600 | 620 | attachments={props.draftAttachments} |
| 601 | 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 602 | 622 | onPressImage={voiceInput.isBusy ? undefined : onPressImage} |
| 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} | |
| 603 | 624 | /> |
| 604 | 625 | </Animated.View> |
| 605 | 626 | ) : null} |
| ... | ... | @@ -645,27 +666,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 645 | 666 | </Animated.View> |
| 646 | 667 | {!isExpanded && props.draftAttachments.length > 0 ? ( |
| 647 | 668 | <View className="flex-row gap-1 pl-1"> |
| 648 | {props.draftAttachments.slice(0, 3).map((attachment) => | |
| 649 | attachment.type === "image" ? ( | |
| 650 | <Pressable | |
| 651 | key={attachment.id} | |
| 652 | onPress={() => onPressImage(attachment.previewUri)} | |
| 653 | > | |
| 654 | <Image | |
| 655 | source={{ uri: attachment.previewUri }} | |
| 656 | className="size-[30px] rounded-lg bg-subtle" | |
| 657 | resizeMode="cover" | |
| 658 | /> | |
| 659 | </Pressable> | |
| 660 | ) : ( | |
| 661 | <View | |
| 662 | key={attachment.id} | |
| 663 | className="size-[30px] items-center justify-center rounded-lg bg-subtle" | |
| 664 | > | |
| 665 | <SymbolView name="doc.text" size={15} tintColor="#a3a3a3" type="monochrome" /> | |
| 666 | </View> | |
| 667 | ), | |
| 668 | )} | |
| 669 | {props.draftAttachments.slice(0, 3).map((attachment) => ( | |
| 670 | <ComposerAttachmentThumbnail | |
| 671 | key={attachment.id} | |
| 672 | attachment={attachment} | |
| 673 | size={30} | |
| 674 | borderRadius={8} | |
| 675 | compact | |
| 676 | onPressImage={onPressImage} | |
| 677 | onPressVideo={onPressVideo} | |
| 678 | /> | |
| 679 | ))} | |
| 669 | 680 | {props.draftAttachments.length > 3 ? ( |
| 670 | 681 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> |
| 671 | 682 | <Text className="text-foreground-muted text-2xs font-t3-bold"> |
| ... | ... | @@ -807,6 +818,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 807 | 818 | ) : null} |
| 808 | 819 | </Animated.View> |
| 809 | 820 | |
| 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> | |
| 810 | 822 | <ImageViewing |
| 811 | 823 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 812 | 824 | imageIndex={0} |
apps/mobile/src/features/threads/ThreadFeed.tsx+104-42| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | splitCodexArtifactTemplateMarkdown, |
| 24 | 24 | } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 25 | 25 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 26 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 26 | 27 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 27 | 28 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 28 | 29 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| ... | ... | @@ -88,6 +89,8 @@ import { |
| 88 | 89 | } from "../../native/SelectableMarkdownText"; |
| 89 | 90 | |
| 90 | 91 | import { AppText as Text } from "../../components/AppText"; |
| 92 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 93 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; | |
| 91 | 94 | import { CopyTextButton } from "../../components/CopyTextButton"; |
| 92 | 95 | import { |
| 93 | 96 | parseReviewCommentMessageSegments, |
| ... | ... | @@ -246,12 +249,25 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt |
| 246 | 249 | function MessageAttachmentFile(props: { |
| 247 | 250 | readonly environmentId: EnvironmentId; |
| 248 | 251 | readonly attachment: ChatFileAttachment; |
| 252 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | |
| 249 | 253 | }) { |
| 250 | 254 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 251 | 255 | reportFailure: false, |
| 252 | 256 | }); |
| 253 | 257 | const preparedConnection = usePreparedConnection(props.environmentId); |
| 254 | 258 | const { attachment } = props; |
| 259 | const videoType = videoMimeType(attachment); | |
| 260 | const thumbnailUrl = useAssetUrl( | |
| 261 | props.environmentId, | |
| 262 | videoType === null | |
| 263 | ? null | |
| 264 | : { | |
| 265 | _tag: "attachment", | |
| 266 | attachmentId: attachment.id, | |
| 267 | fileName: attachment.name, | |
| 268 | mimeType: videoType, | |
| 269 | }, | |
| 270 | ); | |
| 255 | 271 | const httpBaseUrl = Option.isSome(preparedConnection) |
| 256 | 272 | ? preparedConnection.value.httpBaseUrl |
| 257 | 273 | : null; |
| ... | ... | @@ -268,6 +284,69 @@ function MessageAttachmentFile(props: { |
| 268 | 284 | }, [props.environmentId, attachment.id, httpBaseUrl]), |
| 269 | 285 | ); |
| 270 | 286 | |
| 287 | const shareFile = (sourceIdentifier?: string) => { | |
| 288 | if (httpBaseUrl === null || openingRef.current) return; | |
| 289 | const controller = new AbortController(); | |
| 290 | openingRef.current = controller; | |
| 291 | setOpening(true); | |
| 292 | void (async () => { | |
| 293 | try { | |
| 294 | const result = await createAssetUrl({ | |
| 295 | environmentId: props.environmentId, | |
| 296 | input: { | |
| 297 | resource: { | |
| 298 | _tag: "attachment", | |
| 299 | attachmentId: attachment.id, | |
| 300 | fileName: attachment.name, | |
| 301 | mimeType: attachment.mimeType, | |
| 302 | }, | |
| 303 | }, | |
| 304 | }); | |
| 305 | if (controller.signal.aborted) return; | |
| 306 | if (result._tag === "Failure") { | |
| 307 | throw squashAtomCommandFailure(result); | |
| 308 | } | |
| 309 | const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); | |
| 310 | if (url === null) { | |
| 311 | throw new Error("The attachment could not be opened."); | |
| 312 | } | |
| 313 | await downloadAndShareAttachment({ | |
| 314 | url, | |
| 315 | attachment, | |
| 316 | signal: controller.signal, | |
| 317 | sourceIdentifier, | |
| 318 | }); | |
| 319 | } catch (error) { | |
| 320 | if (!controller.signal.aborted) { | |
| 321 | Alert.alert( | |
| 322 | "Could not open attachment", | |
| 323 | error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 324 | ); | |
| 325 | } | |
| 326 | } finally { | |
| 327 | if (openingRef.current === controller) { | |
| 328 | openingRef.current = null; | |
| 329 | setOpening(false); | |
| 330 | } | |
| 331 | } | |
| 332 | })(); | |
| 333 | }; | |
| 334 | ||
| 335 | if (videoType !== null) { | |
| 336 | return ( | |
| 337 | <VideoAttachmentTile | |
| 338 | name={attachment.name} | |
| 339 | sourceIdentifier={`attachment:${props.environmentId}:${attachment.id}`} | |
| 340 | thumbnailSource={thumbnailUrl} | |
| 341 | disabled={opening || httpBaseUrl === null} | |
| 342 | onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} | |
| 343 | onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} | |
| 344 | className="my-1 rounded-2xl" | |
| 345 | style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} | |
| 346 | /> | |
| 347 | ); | |
| 348 | } | |
| 349 | ||
| 271 | 350 | return ( |
| 272 | 351 | <Pressable |
| 273 | 352 | accessibilityRole="button" |
| ... | ... | @@ -275,48 +354,7 @@ function MessageAttachmentFile(props: { |
| 275 | 354 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} |
| 276 | 355 | disabled={opening || httpBaseUrl === null} |
| 277 | 356 | className="flex-row items-center gap-2 py-1" |
| 278 | onPress={() => { | |
| 279 | if (httpBaseUrl === null || openingRef.current) return; | |
| 280 | const controller = new AbortController(); | |
| 281 | openingRef.current = controller; | |
| 282 | setOpening(true); | |
| 283 | void (async () => { | |
| 284 | try { | |
| 285 | const result = await createAssetUrl({ | |
| 286 | environmentId: props.environmentId, | |
| 287 | input: { | |
| 288 | resource: { | |
| 289 | _tag: "attachment", | |
| 290 | attachmentId: attachment.id, | |
| 291 | fileName: attachment.name, | |
| 292 | mimeType: attachment.mimeType, | |
| 293 | }, | |
| 294 | }, | |
| 295 | }); | |
| 296 | if (controller.signal.aborted) return; | |
| 297 | if (result._tag === "Failure") { | |
| 298 | throw squashAtomCommandFailure(result); | |
| 299 | } | |
| 300 | const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); | |
| 301 | if (url === null) { | |
| 302 | throw new Error("The attachment could not be opened."); | |
| 303 | } | |
| 304 | await downloadAndShareAttachment({ url, attachment, signal: controller.signal }); | |
| 305 | } catch (error) { | |
| 306 | if (!controller.signal.aborted) { | |
| 307 | Alert.alert( | |
| 308 | "Could not open attachment", | |
| 309 | error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 310 | ); | |
| 311 | } | |
| 312 | } finally { | |
| 313 | if (openingRef.current === controller) { | |
| 314 | openingRef.current = null; | |
| 315 | setOpening(false); | |
| 316 | } | |
| 317 | } | |
| 318 | })(); | |
| 319 | }} | |
| 357 | onPress={() => shareFile()} | |
| 320 | 358 | > |
| 321 | 359 | {opening ? ( |
| 322 | 360 | <ActivityIndicator size="small" /> |
| ... | ... | @@ -1227,6 +1265,7 @@ function renderFeedEntry( |
| 1227 | 1265 | readonly onToggleWorkRow: (rowId: string) => void; |
| 1228 | 1266 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1229 | 1267 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 1268 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | |
| 1230 | 1269 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1231 | 1270 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| 1232 | 1271 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| ... | ... | @@ -1344,6 +1383,7 @@ function renderFeedEntry( |
| 1344 | 1383 | key={attachment.id} |
| 1345 | 1384 | environmentId={props.environmentId} |
| 1346 | 1385 | attachment={attachment} |
| 1386 | onPressVideo={props.onPressVideo} | |
| 1347 | 1387 | /> |
| 1348 | 1388 | ) : ( |
| 1349 | 1389 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> |
| ... | ... | @@ -1404,6 +1444,7 @@ function renderFeedEntry( |
| 1404 | 1444 | key={attachment.id} |
| 1405 | 1445 | environmentId={props.environmentId} |
| 1406 | 1446 | attachment={attachment} |
| 1447 | onPressVideo={props.onPressVideo} | |
| 1407 | 1448 | /> |
| 1408 | 1449 | ) : ( |
| 1409 | 1450 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> |
| ... | ... | @@ -1769,6 +1810,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1769 | 1810 | uri: string; |
| 1770 | 1811 | headers?: Record<string, string>; |
| 1771 | 1812 | } | null>(null); |
| 1813 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); | |
| 1814 | useEffect(() => { | |
| 1815 | setExpandedVideo(null); | |
| 1816 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); | |
| 1772 | 1817 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1773 | 1818 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| 1774 | 1819 | viewportWidth, |
| ... | ... | @@ -2243,6 +2288,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2243 | 2288 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { |
| 2244 | 2289 | setExpandedImage({ uri, headers }); |
| 2245 | 2290 | }, []); |
| 2291 | const onPressVideo = useCallback( | |
| 2292 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { | |
| 2293 | setExpandedVideo( | |
| 2294 | (current) => | |
| 2295 | current ?? { | |
| 2296 | type: "remote", | |
| 2297 | environmentId: props.environmentId, | |
| 2298 | attachment, | |
| 2299 | sourceIdentifier, | |
| 2300 | }, | |
| 2301 | ); | |
| 2302 | }, | |
| 2303 | [props.environmentId], | |
| 2304 | ); | |
| 2246 | 2305 | |
| 2247 | 2306 | // Rows whose height is known before they ever render. Without this, every |
| 2248 | 2307 | // row above the viewport is assumed to be estimatedItemSize tall, and |
| ... | ... | @@ -2292,6 +2351,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2292 | 2351 | onToggleWorkRow, |
| 2293 | 2352 | onToggleTurnFold, |
| 2294 | 2353 | onPressImage, |
| 2354 | onPressVideo, | |
| 2295 | 2355 | onMarkdownLinkPress, |
| 2296 | 2356 | renderMarkdownImage, |
| 2297 | 2357 | iconSubtleColor, |
| ... | ... | @@ -2320,6 +2380,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2320 | 2380 | onCopyWorkRow, |
| 2321 | 2381 | onMarkdownLinkPress, |
| 2322 | 2382 | onPressImage, |
| 2383 | onPressVideo, | |
| 2323 | 2384 | onToggleTurnFold, |
| 2324 | 2385 | onToggleWorkGroup, |
| 2325 | 2386 | onToggleWorkRow, |
| ... | ... | @@ -2503,6 +2564,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2503 | 2564 | ) : null} |
| 2504 | 2565 | </View> |
| 2505 | 2566 | |
| 2567 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> | |
| 2506 | 2568 | <ImageViewing |
| 2507 | 2569 | images={ |
| 2508 | 2570 | expandedImage |
apps/mobile/src/lib/attachmentDownload.test.ts+133-3| ... | ... | @@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => ({ |
| 4 | 4 | directories: new Set<string>(), |
| 5 | 5 | deleted: vi.fn(), |
| 6 | 6 | download: vi.fn(), |
| 7 | copy: vi.fn(), | |
| 7 | 8 | share: vi.fn(), |
| 9 | shareFromSource: vi.fn(), | |
| 8 | 10 | available: vi.fn(), |
| 9 | 11 | uuid: vi.fn(), |
| 10 | 12 | })); |
| ... | ... | @@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => { |
| 46 | 48 | static downloadFileAsync = mocks.download; |
| 47 | 49 | readonly uri: string; |
| 48 | 50 | |
| 49 | constructor(directory: Directory, name: string) { | |
| 50 | this.uri = `${directory.uri}/${encodeURIComponent(name)}`; | |
| 51 | constructor(source: Directory | string, name?: string) { | |
| 52 | this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`; | |
| 53 | } | |
| 54 | ||
| 55 | async copy(destination: File): Promise<void> { | |
| 56 | await mocks.copy(this.uri, destination.uri); | |
| 51 | 57 | } |
| 52 | 58 | } |
| 53 | 59 | |
| ... | ... | @@ -60,8 +66,13 @@ vi.mock("expo-sharing", () => ({ |
| 60 | 66 | })); |
| 61 | 67 | |
| 62 | 68 | vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); |
| 69 | vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); | |
| 63 | 70 | |
| 64 | import { downloadAndShareAttachment } from "./attachmentDownload"; | |
| 71 | import { | |
| 72 | downloadAndShareAttachment, | |
| 73 | downloadAttachmentForPreview, | |
| 74 | shareLocalAttachment, | |
| 75 | } from "./attachmentDownload"; | |
| 65 | 76 | import { isForegroundHandoffActive } from "./foreground-handoff"; |
| 66 | 77 | |
| 67 | 78 | const NOW = 1_787_990_400_000; |
| ... | ... | @@ -76,11 +87,15 @@ beforeEach(() => { |
| 76 | 87 | mocks.directories.clear(); |
| 77 | 88 | mocks.deleted.mockReset(); |
| 78 | 89 | mocks.download.mockReset(); |
| 90 | mocks.copy.mockReset(); | |
| 79 | 91 | mocks.share.mockReset(); |
| 92 | mocks.shareFromSource.mockReset(); | |
| 80 | 93 | mocks.available.mockReset(); |
| 81 | 94 | mocks.uuid.mockReset(); |
| 82 | 95 | mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); |
| 96 | mocks.copy.mockResolvedValue(undefined); | |
| 83 | 97 | mocks.share.mockResolvedValue(undefined); |
| 98 | mocks.shareFromSource.mockResolvedValue(undefined); | |
| 84 | 99 | mocks.available.mockResolvedValue(true); |
| 85 | 100 | let sequence = 0; |
| 86 | 101 | mocks.uuid.mockImplementation( |
| ... | ... | @@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => { |
| 268 | 283 | await first; |
| 269 | 284 | }); |
| 270 | 285 | }); |
| 286 | ||
| 287 | describe("attachment preview files", () => { | |
| 288 | it("does not start a native request after cancellation during setup", async () => { | |
| 289 | const controller = new AbortController(); | |
| 290 | const loading = downloadAttachmentForPreview({ ...input, signal: controller.signal }); | |
| 291 | controller.abort(); | |
| 292 | await expect(loading).resolves.toBeNull(); | |
| 293 | expect(mocks.download).not.toHaveBeenCalled(); | |
| 294 | expect(mocks.share).not.toHaveBeenCalled(); | |
| 295 | }); | |
| 296 | ||
| 297 | it("downloads for playback without requiring a share sheet and removes the file on close", async () => { | |
| 298 | mocks.available.mockResolvedValue(false); | |
| 299 | const file = await downloadAttachmentForPreview({ | |
| 300 | ...input, | |
| 301 | signal: new AbortController().signal, | |
| 302 | }); | |
| 303 | expect(file?.uri.endsWith("/report.pdf")).toBe(true); | |
| 304 | expect(mocks.available).not.toHaveBeenCalled(); | |
| 305 | expect(mocks.deleted).not.toHaveBeenCalled(); | |
| 306 | file?.dispose(); | |
| 307 | file?.dispose(); | |
| 308 | expect(mocks.deleted).toHaveBeenCalledTimes(1); | |
| 309 | }); | |
| 310 | ||
| 311 | it.each([undefined, "share-button"])( | |
| 312 | "keeps a shared preview after its owner closes (source: %s)", | |
| 313 | async (sourceIdentifier) => { | |
| 314 | const opened = Promise.withResolvers<void>(); | |
| 315 | const sharing = Promise.withResolvers<void>(); | |
| 316 | const nativeShare = sourceIdentifier ? mocks.shareFromSource : mocks.share; | |
| 317 | nativeShare.mockImplementationOnce(() => { | |
| 318 | opened.resolve(); | |
| 319 | return sharing.promise; | |
| 320 | }); | |
| 321 | const file = await downloadAttachmentForPreview({ | |
| 322 | ...input, | |
| 323 | signal: new AbortController().signal, | |
| 324 | }); | |
| 325 | const share = file!.share(new AbortController().signal, sourceIdentifier); | |
| 326 | await opened.promise; | |
| 327 | file!.dispose(); | |
| 328 | expect(mocks.deleted).not.toHaveBeenCalled(); | |
| 329 | expect(isForegroundHandoffActive()).toBe(true); | |
| 330 | sharing.resolve(); | |
| 331 | await share; | |
| 332 | expect(isForegroundHandoffActive()).toBe(false); | |
| 333 | expect(mocks.deleted).not.toHaveBeenCalled(); | |
| 334 | expect(mocks.download).toHaveBeenCalledTimes(1); | |
| 335 | expect(mocks.copy).not.toHaveBeenCalled(); | |
| 336 | }, | |
| 337 | ); | |
| 338 | ||
| 339 | it.each([undefined, "share-button"])( | |
| 340 | "does not share a disposed preview after availability checking (source: %s)", | |
| 341 | async (sourceIdentifier) => { | |
| 342 | const checking = Promise.withResolvers<void>(); | |
| 343 | const available = Promise.withResolvers<boolean>(); | |
| 344 | mocks.available.mockImplementation(() => { | |
| 345 | checking.resolve(); | |
| 346 | return available.promise; | |
| 347 | }); | |
| 348 | const file = await downloadAttachmentForPreview({ | |
| 349 | ...input, | |
| 350 | signal: new AbortController().signal, | |
| 351 | }); | |
| 352 | const share = file!.share(new AbortController().signal, sourceIdentifier); | |
| 353 | await checking.promise; | |
| 354 | file!.dispose(); | |
| 355 | available.resolve(true); | |
| 356 | await share; | |
| 357 | expect(mocks.share).not.toHaveBeenCalled(); | |
| 358 | expect(mocks.shareFromSource).not.toHaveBeenCalled(); | |
| 359 | expect(mocks.deleted).toHaveBeenCalledTimes(1); | |
| 360 | }, | |
| 361 | ); | |
| 362 | ||
| 363 | it("copies a local original before sharing without downloading or deleting the source", async () => { | |
| 364 | const uri = "file:///documents/draft/report.pdf"; | |
| 365 | await shareLocalAttachment({ | |
| 366 | uri, | |
| 367 | attachment: input.attachment, | |
| 368 | signal: new AbortController().signal, | |
| 369 | }); | |
| 370 | expect(mocks.copy).toHaveBeenCalledWith( | |
| 371 | uri, | |
| 372 | expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/), | |
| 373 | ); | |
| 374 | expect(mocks.share).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], expect.any(Object)); | |
| 375 | expect(mocks.download).not.toHaveBeenCalled(); | |
| 376 | expect(mocks.deleted).not.toHaveBeenCalled(); | |
| 377 | }); | |
| 378 | ||
| 379 | it("waits for a local copy to finish before cleaning up a canceled share", async () => { | |
| 380 | const copying = Promise.withResolvers<void>(); | |
| 381 | const copied = Promise.withResolvers<void>(); | |
| 382 | mocks.copy.mockImplementation(() => { | |
| 383 | copying.resolve(); | |
| 384 | return copied.promise; | |
| 385 | }); | |
| 386 | const controller = new AbortController(); | |
| 387 | const task = shareLocalAttachment({ | |
| 388 | uri: "file:///documents/draft/report.pdf", | |
| 389 | attachment: input.attachment, | |
| 390 | signal: controller.signal, | |
| 391 | }); | |
| 392 | await copying.promise; | |
| 393 | controller.abort(); | |
| 394 | expect(mocks.deleted).not.toHaveBeenCalled(); | |
| 395 | copied.resolve(); | |
| 396 | await task; | |
| 397 | expect(mocks.share).not.toHaveBeenCalled(); | |
| 398 | expect(mocks.deleted).toHaveBeenCalledTimes(1); | |
| 399 | }); | |
| 400 | }); |
apps/mobile/src/lib/attachmentDownload.ts+140-40| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { ChatFileAttachment } from "@t3tools/contracts"; |
| 2 | 2 | import type { Directory } from "expo-file-system"; |
| 3 | import type { SharingOptions } from "expo-sharing"; | |
| 3 | 4 | |
| 4 | 5 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 5 | 6 | import { uuidv4 } from "./uuid"; |
| ... | ... | @@ -52,23 +53,27 @@ function removeDownloadDirectory(directory: Directory): void { |
| 52 | 53 | } |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | /** Downloads original bytes for the native save/share sheet, including inline video responses. */ | |
| 56 | export async function downloadAndShareAttachment(input: { | |
| 57 | readonly url: string; | |
| 58 | readonly attachment: Pick<ChatFileAttachment, "name" | "mimeType">; | |
| 59 | readonly signal: AbortSignal; | |
| 60 | }): Promise<void> { | |
| 61 | const [{ Directory, File, Paths }, Sharing] = await Promise.all([ | |
| 62 | import("expo-file-system"), | |
| 63 | import("expo-sharing"), | |
| 64 | ]); | |
| 65 | if (input.signal.aborted) return; | |
| 56 | type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">; | |
| 57 | ||
| 58 | export interface AttachmentPreviewFile { | |
| 59 | readonly uri: string; | |
| 60 | readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>; | |
| 61 | readonly dispose: () => void; | |
| 62 | } | |
| 63 | ||
| 64 | async function availableSharing(signal: AbortSignal) { | |
| 65 | if (signal.aborted) return null; | |
| 66 | const Sharing = await import("expo-sharing"); | |
| 66 | 67 | const canShare = await Sharing.isAvailableAsync(); |
| 67 | if (input.signal.aborted) return; | |
| 68 | if (signal.aborted) return null; | |
| 68 | 69 | if (!canShare) { |
| 69 | 70 | throw new Error("Saving and sharing files is unavailable on this device."); |
| 70 | 71 | } |
| 72 | return Sharing; | |
| 73 | } | |
| 71 | 74 | |
| 75 | async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { | |
| 76 | const { Directory, File, Paths } = await import("expo-file-system"); | |
| 72 | 77 | const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); |
| 73 | 78 | cache.create({ idempotent: true, intermediates: true }); |
| 74 | 79 | const now = Date.now(); |
| ... | ... | @@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: { |
| 89 | 94 | } |
| 90 | 95 | |
| 91 | 96 | const directory = new Directory(cache, `${now}-${uuidv4()}`); |
| 97 | directory.create(); | |
| 98 | let file: InstanceType<typeof File>; | |
| 99 | try { | |
| 100 | file = new File(directory, downloadFileName(attachment.name)); | |
| 101 | } catch (error) { | |
| 102 | removeDownloadDirectory(directory); | |
| 103 | throw error; | |
| 104 | } | |
| 92 | 105 | activeDirectories.add(directory.uri); |
| 106 | let disposed = false; | |
| 93 | 107 | let shared = false; |
| 94 | let openingShareSheet = false; | |
| 95 | try { | |
| 96 | directory.create(); | |
| 97 | const destination = new File(directory, downloadFileName(input.attachment.name)); | |
| 98 | const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal }); | |
| 99 | if (input.signal.aborted) return; | |
| 108 | let sharing = false; | |
| 109 | const release = () => { | |
| 110 | if (!disposed || sharing) return; | |
| 111 | activeDirectories.delete(directory.uri); | |
| 112 | // A receiver can still be reading after Android's chooser returns. | |
| 113 | if (!shared) removeDownloadDirectory(directory); | |
| 114 | }; | |
| 115 | const preview: AttachmentPreviewFile = { | |
| 116 | uri: file.uri, | |
| 117 | dispose: () => { | |
| 118 | disposed = true; | |
| 119 | release(); | |
| 120 | }, | |
| 121 | share: async (signal, sourceIdentifier) => { | |
| 122 | if (disposed || sharing || signal.aborted) return; | |
| 123 | sharing = true; | |
| 124 | try { | |
| 125 | const Sharing = await availableSharing(signal); | |
| 126 | if (Sharing === null || disposed) return; | |
| 127 | const endHandoff = beginForegroundHandoff(); | |
| 128 | try { | |
| 129 | const options: SharingOptions = { | |
| 130 | mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", | |
| 131 | dialogTitle: attachment.name, | |
| 132 | }; | |
| 133 | if (sourceIdentifier) { | |
| 134 | const { shareFileFromSource } = await import("./shareFileFromSource"); | |
| 135 | if (signal.aborted || disposed) return; | |
| 136 | await shareFileFromSource(file.uri, options, sourceIdentifier); | |
| 137 | } else { | |
| 138 | await Sharing.shareAsync(file.uri, options); | |
| 139 | } | |
| 140 | shared = true; | |
| 141 | } catch (cause) { | |
| 142 | if (!signal.aborted) { | |
| 143 | throw new Error("Could not open the share sheet. Try again.", { cause }); | |
| 144 | } | |
| 145 | } finally { | |
| 146 | endHandoff(); | |
| 147 | } | |
| 148 | } finally { | |
| 149 | sharing = false; | |
| 150 | release(); | |
| 151 | } | |
| 152 | }, | |
| 153 | }; | |
| 154 | return { file, preview }; | |
| 155 | } | |
| 100 | 156 | |
| 101 | openingShareSheet = true; | |
| 102 | const endHandoff = beginForegroundHandoff(); | |
| 103 | try { | |
| 104 | await Sharing.shareAsync(file.uri, { | |
| 105 | mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", | |
| 106 | dialogTitle: input.attachment.name, | |
| 107 | }); | |
| 108 | shared = true; | |
| 109 | } finally { | |
| 110 | endHandoff(); | |
| 157 | /** The caller owns this cached file until disposal, unless it has been shared with another app. */ | |
| 158 | export async function downloadAttachmentForPreview(input: { | |
| 159 | readonly url: string; | |
| 160 | readonly attachment: AttachmentFileMetadata; | |
| 161 | readonly signal: AbortSignal; | |
| 162 | }): Promise<AttachmentPreviewFile | null> { | |
| 163 | if (input.signal.aborted) return null; | |
| 164 | const { File } = await import("expo-file-system"); | |
| 165 | const cached = await createCachedAttachmentFile(input.attachment); | |
| 166 | try { | |
| 167 | if (input.signal.aborted) { | |
| 168 | cached.preview.dispose(); | |
| 169 | return null; | |
| 170 | } | |
| 171 | await File.downloadFileAsync(input.url, cached.file, { signal: input.signal }); | |
| 172 | if (input.signal.aborted) { | |
| 173 | cached.preview.dispose(); | |
| 174 | return null; | |
| 111 | 175 | } |
| 176 | return cached.preview; | |
| 112 | 177 | } catch (cause) { |
| 113 | if (input.signal.aborted) return; | |
| 114 | throw new Error( | |
| 115 | openingShareSheet | |
| 116 | ? "Could not open the share sheet. Try again." | |
| 117 | : "Could not download the attachment. Check the connection and try again.", | |
| 118 | { cause }, | |
| 119 | ); | |
| 178 | // Android may leave a partial file after a failed or interrupted request. | |
| 179 | cached.preview.dispose(); | |
| 180 | if (input.signal.aborted) return null; | |
| 181 | throw new Error("Could not download the attachment. Check the connection and try again.", { | |
| 182 | cause, | |
| 183 | }); | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | /** Downloads original bytes for the native save/share sheet, including inline video responses. */ | |
| 188 | export async function downloadAndShareAttachment(input: { | |
| 189 | readonly url: string; | |
| 190 | readonly attachment: AttachmentFileMetadata; | |
| 191 | readonly signal: AbortSignal; | |
| 192 | readonly sourceIdentifier?: string; | |
| 193 | }): Promise<void> { | |
| 194 | if ((await availableSharing(input.signal)) === null) return; | |
| 195 | const file = await downloadAttachmentForPreview(input); | |
| 196 | if (file === null) return; | |
| 197 | try { | |
| 198 | await file.share(input.signal, input.sourceIdentifier); | |
| 120 | 199 | } finally { |
| 121 | activeDirectories.delete(directory.uri); | |
| 122 | // A receiver can still be reading after Android's chooser returns. | |
| 123 | // Successful exports expire on a later open; partial downloads do not. | |
| 124 | if (!shared) { | |
| 125 | removeDownloadDirectory(directory); | |
| 200 | file.dispose(); | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | /** Shares a cache copy so another app never relies on the lifetime of a composer draft. */ | |
| 205 | export async function shareLocalAttachment(input: { | |
| 206 | readonly uri: string; | |
| 207 | readonly attachment: AttachmentFileMetadata; | |
| 208 | readonly signal: AbortSignal; | |
| 209 | readonly sourceIdentifier?: string; | |
| 210 | }): Promise<void> { | |
| 211 | if ((await availableSharing(input.signal)) === null) return; | |
| 212 | const { File } = await import("expo-file-system"); | |
| 213 | const cached = await createCachedAttachmentFile(input.attachment); | |
| 214 | try { | |
| 215 | if (input.signal.aborted) return; | |
| 216 | try { | |
| 217 | await new File(input.uri).copy(cached.file); | |
| 218 | } catch (cause) { | |
| 219 | if (input.signal.aborted) return; | |
| 220 | throw new Error("Could not prepare the attachment for sharing.", { cause }); | |
| 126 | 221 | } |
| 222 | if (!input.signal.aborted) { | |
| 223 | await cached.preview.share(input.signal, input.sourceIdentifier); | |
| 224 | } | |
| 225 | } finally { | |
| 226 | cached.preview.dispose(); | |
| 127 | 227 | } |
| 128 | 228 | } |
apps/mobile/src/lib/composerAttachmentFiles.ts+25| ... | ... | @@ -6,6 +6,7 @@ const IOS_DOCUMENTS_PATH = new RegExp( |
| 6 | 6 | `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, |
| 7 | 7 | "i", |
| 8 | 8 | ); |
| 9 | const retainedFiles = new Map<string, number>(); | |
| 9 | 10 | |
| 10 | 11 | function fileUriPath(uri: string): string | null { |
| 11 | 12 | try { |
| ... | ... | @@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string { |
| 52 | 53 | return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; |
| 53 | 54 | } |
| 54 | 55 | |
| 56 | /** Holds a local copy until its last player or share-copy operation releases it. */ | |
| 57 | export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void { | |
| 58 | const key = composerAttachmentFileReferenceKey(uri); | |
| 59 | retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1); | |
| 60 | let released = false; | |
| 61 | return () => { | |
| 62 | if (released) { | |
| 63 | return; | |
| 64 | } | |
| 65 | released = true; | |
| 66 | const remaining = (retainedFiles.get(key) ?? 1) - 1; | |
| 67 | if (remaining > 0) { | |
| 68 | retainedFiles.set(key, remaining); | |
| 69 | return; | |
| 70 | } | |
| 71 | retainedFiles.delete(key); | |
| 72 | onLastRelease(); | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | export function isComposerAttachmentFileRetained(uri: string): boolean { | |
| 77 | return retainedFiles.has(composerAttachmentFileReferenceKey(uri)); | |
| 78 | } | |
| 79 | ||
| 55 | 80 | /** |
| 56 | 81 | * Resolves only our saved attachment copies. iOS preserves Documents on updates |
| 57 | 82 | * but can change its container UUID. Picker and open-in-place source URIs must |
apps/mobile/src/lib/composerFiles.test.ts+147-21| ... | ... | @@ -23,8 +23,6 @@ vi.mock("expo-file-system", () => { |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | class File { |
| 26 | static pickFileAsync = mocks.pickFile; | |
| 27 | ||
| 28 | 26 | readonly uri: string; |
| 29 | 27 | |
| 30 | 28 | constructor(source: string | Directory, name?: string) { |
| ... | ... | @@ -75,6 +73,7 @@ vi.mock("expo-file-system", () => { |
| 75 | 73 | }); |
| 76 | 74 | |
| 77 | 75 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); |
| 76 | vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); | |
| 78 | 77 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); |
| 79 | 78 | |
| 80 | 79 | import { |
| ... | ... | @@ -85,6 +84,7 @@ import { |
| 85 | 84 | removePersistedComposerAttachmentFile, |
| 86 | 85 | } from "./composerImages"; |
| 87 | 86 | import { isForegroundHandoffActive } from "./foreground-handoff"; |
| 87 | import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; | |
| 88 | 88 | |
| 89 | 89 | describe("composer file attachments", () => { |
| 90 | 90 | beforeEach(() => { |
| ... | ... | @@ -274,11 +274,11 @@ describe("composer file attachments", () => { |
| 274 | 274 | it("copies picked files into app-owned storage without loading their contents", async () => { |
| 275 | 275 | mocks.pickFile.mockResolvedValue({ |
| 276 | 276 | canceled: false, |
| 277 | result: [ | |
| 277 | assets: [ | |
| 278 | 278 | { |
| 279 | 279 | uri: "file:///downloads/report.pdf", |
| 280 | 280 | name: "report.pdf", |
| 281 | type: "application/pdf", | |
| 281 | mimeType: "application/pdf", | |
| 282 | 282 | size: 42, |
| 283 | 283 | }, |
| 284 | 284 | ], |
| ... | ... | @@ -303,14 +303,120 @@ describe("composer file attachments", () => { |
| 303 | 303 | ); |
| 304 | 304 | }); |
| 305 | 305 | |
| 306 | it("preserves Android picker metadata instead of using the content URI document id", async () => { | |
| 307 | const uri = "content://com.android.providers.media.documents/document/video%3A18"; | |
| 308 | mocks.pickFile.mockResolvedValue({ | |
| 309 | canceled: false, | |
| 310 | assets: [ | |
| 311 | { | |
| 312 | uri, | |
| 313 | name: "preview-h264.mp4", | |
| 314 | mimeType: "video/mp4", | |
| 315 | size: 620_992, | |
| 316 | lastModified: 0, | |
| 317 | }, | |
| 318 | ], | |
| 319 | }); | |
| 320 | mocks.size.mockReturnValue(620_992); | |
| 321 | ||
| 322 | await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ | |
| 323 | files: [ | |
| 324 | { | |
| 325 | id: "attachment-id", | |
| 326 | type: "file", | |
| 327 | name: "preview-h264.mp4", | |
| 328 | mimeType: "video/mp4", | |
| 329 | sizeBytes: 620_992, | |
| 330 | fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", | |
| 331 | }, | |
| 332 | ], | |
| 333 | error: null, | |
| 334 | }); | |
| 335 | expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true }); | |
| 336 | expect(mocks.copy).toHaveBeenCalledWith( | |
| 337 | uri, | |
| 338 | "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", | |
| 339 | ); | |
| 340 | expect(mocks.delete).not.toHaveBeenCalled(); | |
| 341 | }); | |
| 342 | ||
| 343 | it("persists provider selections that require a readable cache copy", async () => { | |
| 344 | const providerUri = "content://cloud-provider/documents/clip"; | |
| 345 | const cachedUri = "file:///cache/DocumentPicker/clip.mp4"; | |
| 346 | mocks.pickFile.mockImplementation(async (options) => ({ | |
| 347 | canceled: false, | |
| 348 | assets: [ | |
| 349 | { | |
| 350 | uri: options.copyToCacheDirectory ? cachedUri : providerUri, | |
| 351 | name: "Cloud recording.mp4", | |
| 352 | mimeType: "video/mp4", | |
| 353 | size: 42, | |
| 354 | lastModified: 0, | |
| 355 | }, | |
| 356 | ], | |
| 357 | })); | |
| 358 | mocks.copy.mockImplementation((uri: string) => { | |
| 359 | if (uri === providerUri) throw new Error("The provider URI is not directly readable."); | |
| 360 | }); | |
| 361 | ||
| 362 | const result = await pickComposerFiles({ existingCount: 0 }); | |
| 363 | ||
| 364 | expect(result.error).toBeNull(); | |
| 365 | expect(result.files).toEqual([ | |
| 366 | expect.objectContaining({ | |
| 367 | name: "Cloud recording.mp4", | |
| 368 | fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4", | |
| 369 | }), | |
| 370 | ]); | |
| 371 | expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri); | |
| 372 | }); | |
| 373 | ||
| 374 | it("ends the foreground handoff when the picker is canceled without copying files", async () => { | |
| 375 | mocks.pickFile.mockImplementation(async () => { | |
| 376 | expect(isForegroundHandoffActive()).toBe(true); | |
| 377 | return { canceled: true, assets: null }; | |
| 378 | }); | |
| 379 | ||
| 380 | await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ | |
| 381 | files: [], | |
| 382 | error: null, | |
| 383 | }); | |
| 384 | ||
| 385 | expect(isForegroundHandoffActive()).toBe(false); | |
| 386 | expect(mocks.copy).not.toHaveBeenCalled(); | |
| 387 | expect(mocks.open).not.toHaveBeenCalled(); | |
| 388 | }); | |
| 389 | ||
| 390 | it("reports picker failures and releases the foreground handoff", async () => { | |
| 391 | mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable.")); | |
| 392 | ||
| 393 | await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ | |
| 394 | files: [], | |
| 395 | error: "The document provider is unavailable.", | |
| 396 | }); | |
| 397 | ||
| 398 | expect(isForegroundHandoffActive()).toBe(false); | |
| 399 | expect(mocks.copy).not.toHaveBeenCalled(); | |
| 400 | }); | |
| 401 | ||
| 402 | it("does not open the picker when the draft has no remaining attachment slots", async () => { | |
| 403 | await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ | |
| 404 | files: [], | |
| 405 | error: "You can attach up to 8 files per message.", | |
| 406 | }); | |
| 407 | ||
| 408 | expect(mocks.pickFile).not.toHaveBeenCalled(); | |
| 409 | expect(isForegroundHandoffActive()).toBe(false); | |
| 410 | }); | |
| 411 | ||
| 306 | 412 | it("falls back to a usable name when the picker reports a blank one", async () => { |
| 307 | 413 | mocks.pickFile.mockResolvedValue({ |
| 308 | 414 | canceled: false, |
| 309 | result: [ | |
| 415 | assets: [ | |
| 310 | 416 | { |
| 311 | 417 | uri: "file:///downloads/unnamed", |
| 312 | 418 | name: " ", |
| 313 | type: "application/pdf", | |
| 419 | mimeType: "application/pdf", | |
| 314 | 420 | size: 42, |
| 315 | 421 | }, |
| 316 | 422 | ], |
| ... | ... | @@ -325,11 +431,11 @@ describe("composer file attachments", () => { |
| 325 | 431 | it("rejects files that exceed the environment's advertised upload limit", async () => { |
| 326 | 432 | mocks.pickFile.mockResolvedValue({ |
| 327 | 433 | canceled: false, |
| 328 | result: [ | |
| 434 | assets: [ | |
| 329 | 435 | { |
| 330 | 436 | uri: "file:///downloads/archive.zip", |
| 331 | 437 | name: "archive.zip", |
| 332 | type: "application/zip", | |
| 438 | mimeType: "application/zip", | |
| 333 | 439 | size: 2 * 1024 * 1024, |
| 334 | 440 | }, |
| 335 | 441 | ], |
| ... | ... | @@ -345,11 +451,11 @@ describe("composer file attachments", () => { |
| 345 | 451 | it("never accepts files above the 50 MB contract limit", async () => { |
| 346 | 452 | mocks.pickFile.mockResolvedValue({ |
| 347 | 453 | canceled: false, |
| 348 | result: [ | |
| 454 | assets: [ | |
| 349 | 455 | { |
| 350 | 456 | uri: "file:///downloads/archive.zip", |
| 351 | 457 | name: "archive.zip", |
| 352 | type: "application/zip", | |
| 458 | mimeType: "application/zip", | |
| 353 | 459 | size: 51 * 1024 * 1024, |
| 354 | 460 | }, |
| 355 | 461 | ], |
| ... | ... | @@ -366,11 +472,11 @@ describe("composer file attachments", () => { |
| 366 | 472 | it("rejects a file that grew after the picker reported its size", async () => { |
| 367 | 473 | mocks.pickFile.mockResolvedValue({ |
| 368 | 474 | canceled: false, |
| 369 | result: [ | |
| 475 | assets: [ | |
| 370 | 476 | { |
| 371 | 477 | uri: "file:///downloads/archive.zip", |
| 372 | 478 | name: "archive.zip", |
| 373 | type: "application/zip", | |
| 479 | mimeType: "application/zip", | |
| 374 | 480 | size: 42, |
| 375 | 481 | }, |
| 376 | 482 | ], |
| ... | ... | @@ -434,11 +540,11 @@ describe("composer file attachments", () => { |
| 434 | 540 | mocks.size.mockReturnValue(0); |
| 435 | 541 | mocks.pickFile.mockResolvedValue({ |
| 436 | 542 | canceled: false, |
| 437 | result: [ | |
| 543 | assets: [ | |
| 438 | 544 | { |
| 439 | 545 | uri: "file:///downloads/empty.txt", |
| 440 | 546 | name: "empty.txt", |
| 441 | type: "text/plain", | |
| 547 | mimeType: "text/plain", | |
| 442 | 548 | size: 0, |
| 443 | 549 | }, |
| 444 | 550 | ], |
| ... | ... | @@ -450,7 +556,7 @@ describe("composer file attachments", () => { |
| 450 | 556 | }); |
| 451 | 557 | }); |
| 452 | 558 | |
| 453 | it("copies an Android SAF file when the picker reports an unknown zero size", async () => { | |
| 559 | it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => { | |
| 454 | 560 | const reader = { |
| 455 | 561 | readBytes: vi |
| 456 | 562 | .fn() |
| ... | ... | @@ -463,12 +569,12 @@ describe("composer file attachments", () => { |
| 463 | 569 | mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); |
| 464 | 570 | mocks.pickFile.mockResolvedValue({ |
| 465 | 571 | canceled: false, |
| 466 | result: [ | |
| 572 | assets: [ | |
| 467 | 573 | { |
| 468 | 574 | uri: "content://shared/report", |
| 469 | 575 | name: "report.pdf", |
| 470 | type: "application/pdf", | |
| 471 | size: 0, | |
| 576 | mimeType: "application/pdf", | |
| 577 | size, | |
| 472 | 578 | }, |
| 473 | 579 | ], |
| 474 | 580 | }); |
| ... | ... | @@ -491,17 +597,17 @@ describe("composer file attachments", () => { |
| 491 | 597 | it("uses the remaining slot for the first valid file after an oversized selection", async () => { |
| 492 | 598 | mocks.pickFile.mockResolvedValue({ |
| 493 | 599 | canceled: false, |
| 494 | result: [ | |
| 600 | assets: [ | |
| 495 | 601 | { |
| 496 | 602 | uri: "file:///downloads/huge.zip", |
| 497 | 603 | name: "huge.zip", |
| 498 | type: "application/zip", | |
| 604 | mimeType: "application/zip", | |
| 499 | 605 | size: 2 * 1024 * 1024, |
| 500 | 606 | }, |
| 501 | 607 | { |
| 502 | 608 | uri: "file:///downloads/report.pdf", |
| 503 | 609 | name: "report.pdf", |
| 504 | type: "application/pdf", | |
| 610 | mimeType: "application/pdf", | |
| 505 | 611 | size: 42, |
| 506 | 612 | }, |
| 507 | 613 | ], |
| ... | ... | @@ -557,6 +663,26 @@ describe("composer file attachments", () => { |
| 557 | 663 | ]); |
| 558 | 664 | }); |
| 559 | 665 | |
| 666 | it("rechecks preview ownership after loading the native filesystem", async () => { | |
| 667 | const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; | |
| 668 | const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; | |
| 669 | mocks.documentUri = | |
| 670 | "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; | |
| 671 | const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`; | |
| 672 | ||
| 673 | const deleting = removePersistedComposerAttachmentFile(oldUri); | |
| 674 | const release = retainComposerAttachmentFile(currentUri, () => {}); | |
| 675 | try { | |
| 676 | await deleting; | |
| 677 | expect(mocks.delete).not.toHaveBeenCalled(); | |
| 678 | } finally { | |
| 679 | release(); | |
| 680 | } | |
| 681 | ||
| 682 | await removePersistedComposerAttachmentFile(oldUri); | |
| 683 | expect(mocks.delete.mock.calls).toEqual([[currentUri]]); | |
| 684 | }); | |
| 685 | ||
| 560 | 686 | it("copies an open-in-place source from its actual container without rebasing it", async () => { |
| 561 | 687 | const sourceUri = |
| 562 | 688 | "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf"; |
apps/mobile/src/lib/composerImages.ts+15-7| ... | ... | @@ -10,10 +10,11 @@ import { |
| 10 | 10 | type EnvironmentId, |
| 11 | 11 | type UploadChatImageAttachment, |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | import type { PickMultipleFilesResult } from "expo-file-system"; | |
| 13 | import type { DocumentPickerResult } from "expo-document-picker"; | |
| 14 | 14 | import { estimateBase64ByteSize } from "./base64"; |
| 15 | 15 | import { |
| 16 | 16 | COMPOSER_ATTACHMENT_DIRECTORY, |
| 17 | isComposerAttachmentFileRetained, | |
| 17 | 18 | resolveOwnedComposerAttachmentFileUri, |
| 18 | 19 | } from "./composerAttachmentFiles"; |
| 19 | 20 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| ... | ... | @@ -145,7 +146,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis |
| 145 | 146 | try { |
| 146 | 147 | const { File, Paths } = await import("expo-file-system"); |
| 147 | 148 | const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); |
| 148 | if (ownedUri === null) { | |
| 149 | if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) { | |
| 149 | 150 | return; |
| 150 | 151 | } |
| 151 | 152 | const file = new File(ownedUri); |
| ... | ... | @@ -206,11 +207,18 @@ export async function pickComposerFiles(input: { |
| 206 | 207 | }; |
| 207 | 208 | } |
| 208 | 209 | |
| 209 | const { File } = await import("expo-file-system"); | |
| 210 | const { getDocumentAsync } = await import("expo-document-picker"); | |
| 210 | 211 | const endHandoff = beginForegroundHandoff(); |
| 211 | let result: PickMultipleFilesResult; | |
| 212 | let result: DocumentPickerResult; | |
| 212 | 213 | try { |
| 213 | result = await File.pickFileAsync({ multipleFiles: true }); | |
| 214 | // File providers may expose a URI that FileSystem cannot read directly. | |
| 215 | // Import a readable cache copy before persisting the draft's owned file. | |
| 216 | result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true }); | |
| 217 | } catch (cause) { | |
| 218 | return { | |
| 219 | files: [], | |
| 220 | error: cause instanceof Error ? cause.message : "Could not open the file picker.", | |
| 221 | }; | |
| 214 | 222 | } finally { |
| 215 | 223 | endHandoff(); |
| 216 | 224 | } |
| ... | ... | @@ -224,7 +232,7 @@ export async function pickComposerFiles(input: { |
| 224 | 232 | const attachments: DraftComposerFileAttachment[] = []; |
| 225 | 233 | let error: string | null = null; |
| 226 | 234 | let exceededAttachmentLimit = false; |
| 227 | for (const file of result.result) { | |
| 235 | for (const file of result.assets) { | |
| 228 | 236 | if (attachments.length >= remainingSlots) { |
| 229 | 237 | exceededAttachmentLimit = true; |
| 230 | 238 | break; |
| ... | ... | @@ -238,7 +246,7 @@ export async function pickComposerFiles(input: { |
| 238 | 246 | await createComposerFileAttachment({ |
| 239 | 247 | uri: file.uri, |
| 240 | 248 | name, |
| 241 | mimeType: file.type || "application/octet-stream", | |
| 249 | mimeType: file.mimeType || "application/octet-stream", | |
| 242 | 250 | sizeBytes: file.size ?? null, |
| 243 | 251 | maxBytes, |
| 244 | 252 | }), |
apps/mobile/src/lib/localVideoPreview.test.ts created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 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 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 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 | } |
apps/mobile/src/lib/shareFileFromSource.ios.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { requireNativeModule } from "expo"; | |
| 2 | import type { SharingOptions } from "expo-sharing"; | |
| 3 | ||
| 4 | const NativeControls = requireNativeModule<{ | |
| 5 | shareFileFromSource(uri: string, title: string, sourceIdentifier: string): Promise<void>; | |
| 6 | }>("T3NativeControls"); | |
| 7 | ||
| 8 | export function shareFileFromSource( | |
| 9 | uri: string, | |
| 10 | options: SharingOptions, | |
| 11 | sourceIdentifier: string, | |
| 12 | ) { | |
| 13 | return NativeControls.shareFileFromSource(uri, options.dialogTitle ?? "", sourceIdentifier); | |
| 14 | } |
apps/mobile/src/lib/shareFileFromSource.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import { shareAsync, type SharingOptions } from "expo-sharing"; | |
| 2 | ||
| 3 | export function shareFileFromSource( | |
| 4 | uri: string, | |
| 5 | options: SharingOptions, | |
| 6 | _sourceIdentifier: string, | |
| 7 | ) { | |
| 8 | return shareAsync(uri, options); | |
| 9 | } |
apps/mobile/src/lib/videoThumbnails.test.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() })); | |
| 4 | vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); | |
| 5 | ||
| 6 | let thumbnails: typeof import("./videoThumbnails"); | |
| 7 | const frame = { width: 480, height: 270 }; | |
| 8 | const player = () => ({ | |
| 9 | replaceAsync: vi.fn(async (): Promise<void> => {}), | |
| 10 | generateThumbnailsAsync: vi.fn(async () => [frame]), | |
| 11 | release: vi.fn(), | |
| 12 | }); | |
| 13 | const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); | |
| 14 | ||
| 15 | beforeEach(async () => { | |
| 16 | vi.resetModules(); | |
| 17 | mocks.createPlayer.mockReset().mockImplementation(player); | |
| 18 | thumbnails = await import("./videoThumbnails"); | |
| 19 | }); | |
| 20 | ||
| 21 | afterEach(() => vi.useRealTimers()); | |
| 22 | ||
| 23 | describe("video thumbnails", () => { | |
| 24 | it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { | |
| 25 | const file = source(); | |
| 26 | const resolveSource = vi.fn(async () => file); | |
| 27 | const signal = new AbortController().signal; | |
| 28 | const results = await Promise.all([ | |
| 29 | thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), | |
| 30 | thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), | |
| 31 | ]); | |
| 32 | expect(results).toEqual([frame, frame]); | |
| 33 | expect(resolveSource).toHaveBeenCalledTimes(1); | |
| 34 | expect(mocks.createPlayer).toHaveBeenCalledTimes(1); | |
| 35 | expect(file.dispose).toHaveBeenCalledTimes(1); | |
| 36 | const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); | |
| 37 | expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); | |
| 38 | expect(refreshed).not.toHaveBeenCalled(); | |
| 39 | }); | |
| 40 | ||
| 41 | it("serializes decoding and skips queued requests that scroll out of view", async () => { | |
| 42 | const started = Promise.withResolvers<void>(); | |
| 43 | const generated = Promise.withResolvers<(typeof frame)[]>(); | |
| 44 | const first = player(); | |
| 45 | first.generateThumbnailsAsync.mockImplementation(() => { | |
| 46 | started.resolve(); | |
| 47 | return generated.promise; | |
| 48 | }); | |
| 49 | mocks.createPlayer.mockReturnValueOnce(first); | |
| 50 | const firstRequest = thumbnails.loadVideoThumbnail( | |
| 51 | "first", | |
| 52 | async () => source(), | |
| 53 | new AbortController().signal, | |
| 54 | ); | |
| 55 | await started.promise; | |
| 56 | const removed = new AbortController(); | |
| 57 | const skipped = vi.fn(async () => source()); | |
| 58 | const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal); | |
| 59 | const next = vi.fn(async () => source()); | |
| 60 | const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal); | |
| 61 | expect(next).not.toHaveBeenCalled(); | |
| 62 | removed.abort(); | |
| 63 | generated.resolve([frame]); | |
| 64 | expect(await firstRequest).toBe(frame); | |
| 65 | expect(await queued).toBeNull(); | |
| 66 | expect(await nextRequest).toBe(frame); | |
| 67 | expect(skipped).not.toHaveBeenCalled(); | |
| 68 | expect(first.release).toHaveBeenCalledTimes(1); | |
| 69 | }); | |
| 70 | ||
| 71 | it("releases an active canceled player and ignores late source loading", async () => { | |
| 72 | const started = Promise.withResolvers<void>(); | |
| 73 | const replaced = Promise.withResolvers<void>(); | |
| 74 | const first = player(); | |
| 75 | first.replaceAsync.mockImplementation(() => { | |
| 76 | started.resolve(); | |
| 77 | return replaced.promise; | |
| 78 | }); | |
| 79 | mocks.createPlayer.mockReturnValueOnce(first); | |
| 80 | const file = source(); | |
| 81 | const controller = new AbortController(); | |
| 82 | const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); | |
| 83 | await started.promise; | |
| 84 | controller.abort(); | |
| 85 | expect(await request).toBeNull(); | |
| 86 | expect(first.release).toHaveBeenCalledTimes(1); | |
| 87 | expect(file.dispose).toHaveBeenCalledTimes(1); | |
| 88 | replaced.resolve(); | |
| 89 | expect( | |
| 90 | await thumbnails.loadVideoThumbnail( | |
| 91 | "next", | |
| 92 | async () => source(), | |
| 93 | new AbortController().signal, | |
| 94 | ), | |
| 95 | ).toBe(frame); | |
| 96 | expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); | |
| 97 | expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); | |
| 98 | }); | |
| 99 | ||
| 100 | it("releases failed extractions and permits a later retry", async () => { | |
| 101 | const broken = player(); | |
| 102 | broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); | |
| 103 | mocks.createPlayer.mockReturnValueOnce(broken); | |
| 104 | const file = source(); | |
| 105 | expect( | |
| 106 | await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), | |
| 107 | ).toBeNull(); | |
| 108 | expect(broken.release).toHaveBeenCalledTimes(1); | |
| 109 | expect(file.dispose).toHaveBeenCalledTimes(1); | |
| 110 | expect( | |
| 111 | await thumbnails.loadVideoThumbnail( | |
| 112 | "retry", | |
| 113 | async () => source(), | |
| 114 | new AbortController().signal, | |
| 115 | ), | |
| 116 | ).toBe(frame); | |
| 117 | }); | |
| 118 | ||
| 119 | it("does not let an unreachable source block the queue indefinitely", async () => { | |
| 120 | vi.useFakeTimers(); | |
| 121 | const started = Promise.withResolvers<void>(); | |
| 122 | const first = player(); | |
| 123 | first.replaceAsync.mockImplementation(() => { | |
| 124 | started.resolve(); | |
| 125 | return new Promise(() => {}); | |
| 126 | }); | |
| 127 | mocks.createPlayer.mockReturnValueOnce(first); | |
| 128 | const file = source(); | |
| 129 | const request = thumbnails.loadVideoThumbnail( | |
| 130 | "unreachable", | |
| 131 | async () => file, | |
| 132 | new AbortController().signal, | |
| 133 | ); | |
| 134 | await started.promise; | |
| 135 | await vi.advanceTimersByTimeAsync(15_000); | |
| 136 | expect(await request).toBeNull(); | |
| 137 | expect(first.release).toHaveBeenCalledTimes(1); | |
| 138 | expect(file.dispose).toHaveBeenCalledTimes(1); | |
| 139 | expect( | |
| 140 | await thumbnails.loadVideoThumbnail( | |
| 141 | "reachable", | |
| 142 | async () => source(), | |
| 143 | new AbortController().signal, | |
| 144 | ), | |
| 145 | ).toBe(frame); | |
| 146 | }); | |
| 147 | ||
| 148 | it("bounds the retained native images without invalidating frames still displayed", async () => { | |
| 149 | for (let i = 0; i < 33; i++) { | |
| 150 | await thumbnails.loadVideoThumbnail( | |
| 151 | `clip:${i}`, | |
| 152 | async () => source(), | |
| 153 | new AbortController().signal, | |
| 154 | ); | |
| 155 | } | |
| 156 | expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); | |
| 157 | expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); | |
| 158 | expect(mocks.createPlayer).toHaveBeenCalledTimes(33); | |
| 159 | expect( | |
| 160 | await thumbnails.loadVideoThumbnail( | |
| 161 | "clip:0", | |
| 162 | async () => source(), | |
| 163 | new AbortController().signal, | |
| 164 | ), | |
| 165 | ).toBe(frame); | |
| 166 | expect(mocks.createPlayer).toHaveBeenCalledTimes(34); | |
| 167 | }); | |
| 168 | }); |
apps/mobile/src/lib/videoThumbnails.ts created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import type { VideoThumbnail } from "expo-video"; | |
| 2 | ||
| 3 | import type { AttachmentPreviewFile } from "./attachmentDownload"; | |
| 4 | ||
| 5 | const thumbnails = new Map<string, VideoThumbnail>(); | |
| 6 | const MAX_CACHED_THUMBNAILS = 32; | |
| 7 | let pending: Promise<unknown> = Promise.resolve(); | |
| 8 | ||
| 9 | export function cachedVideoThumbnail(key: string): VideoThumbnail | null { | |
| 10 | return thumbnails.get(key) ?? null; | |
| 11 | } | |
| 12 | ||
| 13 | async function extractFrame(uri: string, signal: AbortSignal) { | |
| 14 | const { createVideoPlayer } = await import("expo-video"); | |
| 15 | if (signal.aborted) return null; | |
| 16 | const player = createVideoPlayer(null); | |
| 17 | let disposed = false; | |
| 18 | let cancel = () => {}; | |
| 19 | let timeout: ReturnType<typeof setTimeout> | undefined; | |
| 20 | try { | |
| 21 | // Never play or change audio settings: thumbnails must leave the shared audio session alone. | |
| 22 | player.bufferOptions = { preferredForwardBufferDuration: 1 }; | |
| 23 | const canceled = new Promise<null>((resolve) => { | |
| 24 | cancel = () => resolve(null); | |
| 25 | }); | |
| 26 | signal.addEventListener("abort", cancel, { once: true }); | |
| 27 | // An unreachable environment must not hold up thumbnails for other environments. | |
| 28 | timeout = setTimeout(cancel, 15_000); | |
| 29 | const frame = (async () => { | |
| 30 | await player.replaceAsync({ uri, contentType: "progressive" }); | |
| 31 | if (disposed || signal.aborted) return null; | |
| 32 | const [thumbnail] = await player.generateThumbnailsAsync([0], { | |
| 33 | maxWidth: 480, | |
| 34 | maxHeight: 480, | |
| 35 | }); | |
| 36 | return thumbnail ?? null; | |
| 37 | })(); | |
| 38 | return await Promise.race([frame, canceled]); | |
| 39 | } finally { | |
| 40 | disposed = true; | |
| 41 | clearTimeout(timeout); | |
| 42 | signal.removeEventListener("abort", cancel); | |
| 43 | player.release(); | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** Serializes frame extraction and releases each temporary player and local-file lease. */ | |
| 48 | export function loadVideoThumbnail( | |
| 49 | key: string, | |
| 50 | resolveSource: ( | |
| 51 | signal: AbortSignal, | |
| 52 | ) => Promise<Pick<AttachmentPreviewFile, "uri" | "dispose"> | null>, | |
| 53 | signal: AbortSignal, | |
| 54 | ): Promise<VideoThumbnail | null> { | |
| 55 | if (signal.aborted) return Promise.resolve(null); | |
| 56 | const cached = cachedVideoThumbnail(key); | |
| 57 | if (cached) return Promise.resolve(cached); | |
| 58 | const load = pending | |
| 59 | .then(async () => { | |
| 60 | if (signal.aborted) return null; | |
| 61 | const cached = cachedVideoThumbnail(key); | |
| 62 | if (cached) return cached; | |
| 63 | ||
| 64 | const source = await resolveSource(signal); | |
| 65 | if (!source) return null; | |
| 66 | try { | |
| 67 | const thumbnail = await extractFrame(source.uri, signal); | |
| 68 | if (!thumbnail || signal.aborted) return null; | |
| 69 | thumbnails.set(key, thumbnail); | |
| 70 | if (thumbnails.size > MAX_CACHED_THUMBNAILS) { | |
| 71 | thumbnails.delete(thumbnails.keys().next().value!); | |
| 72 | } | |
| 73 | return thumbnail; | |
| 74 | } finally { | |
| 75 | source.dispose(); | |
| 76 | } | |
| 77 | }) | |
| 78 | .catch(() => null); | |
| 79 | pending = load; | |
| 80 | return load; | |
| 81 | } |
apps/mobile/src/state/use-composer-drafts.test.ts+77| ... | ... | @@ -134,6 +134,7 @@ import { |
| 134 | 134 | releaseUnusedComposerAttachmentFiles, |
| 135 | 135 | removeComposerDraftsForEnvironment, |
| 136 | 136 | resetComposerDraftsLoadState, |
| 137 | retainComposerAttachmentFileForPreview, | |
| 137 | 138 | restoreComposerDraftSnapshotState, |
| 138 | 139 | setComposerDraftText, |
| 139 | 140 | setStickyComposerModelSelection, |
| ... | ... | @@ -319,6 +320,82 @@ describe("mobile composer drafts", () => { |
| 319 | 320 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); |
| 320 | 321 | }); |
| 321 | 322 | |
| 323 | it("keeps a removed file until both playback and a share copy finish", async () => { | |
| 324 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 325 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 326 | const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; | |
| 327 | const file = { | |
| 328 | id: "file-preview", | |
| 329 | type: "file" as const, | |
| 330 | name: "recording.mp4", | |
| 331 | mimeType: "video/mp4", | |
| 332 | sizeBytes: 42, | |
| 333 | fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, | |
| 334 | }; | |
| 335 | const currentFile = { | |
| 336 | ...file, | |
| 337 | fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, | |
| 338 | }; | |
| 339 | const releasePlayback = retainComposerAttachmentFileForPreview(file); | |
| 340 | const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); | |
| 341 | onTestFinished(releasePlayback); | |
| 342 | onTestFinished(releaseShareCopy); | |
| 343 | ||
| 344 | await releaseUnusedComposerAttachmentFiles([currentFile]); | |
| 345 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 346 | ||
| 347 | releasePlayback(); | |
| 348 | releasePlayback(); | |
| 349 | await releaseUnusedComposerAttachmentFiles([file]); | |
| 350 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 351 | ||
| 352 | const deleted = Promise.withResolvers<void>(); | |
| 353 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 354 | deleted.resolve(); | |
| 355 | return undefined; | |
| 356 | }); | |
| 357 | releaseShareCopy(); | |
| 358 | await deleted.promise; | |
| 359 | ||
| 360 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); | |
| 361 | }); | |
| 362 | ||
| 363 | it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { | |
| 364 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 365 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 366 | const file = { | |
| 367 | id: "file-opening-preview", | |
| 368 | type: "file" as const, | |
| 369 | name: "recording.mp4", | |
| 370 | mimeType: "video/mp4", | |
| 371 | sizeBytes: 42, | |
| 372 | fileUri: "file:///documents/t3-composer-attachments/recording.mp4", | |
| 373 | }; | |
| 374 | const ownershipReadStarted = Promise.withResolvers<void>(); | |
| 375 | const ownershipRead = Promise.withResolvers<[]>(); | |
| 376 | incomingShareStorageMocks.load.mockImplementationOnce(() => { | |
| 377 | ownershipReadStarted.resolve(); | |
| 378 | return ownershipRead.promise; | |
| 379 | }); | |
| 380 | ||
| 381 | const cleanup = releaseUnusedComposerAttachmentFiles([file]); | |
| 382 | await ownershipReadStarted.promise; | |
| 383 | const release = retainComposerAttachmentFileForPreview(file); | |
| 384 | onTestFinished(release); | |
| 385 | ownershipRead.resolve([]); | |
| 386 | await cleanup; | |
| 387 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 388 | ||
| 389 | const deleted = Promise.withResolvers<void>(); | |
| 390 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 391 | deleted.resolve(); | |
| 392 | return undefined; | |
| 393 | }); | |
| 394 | release(); | |
| 395 | await deleted.promise; | |
| 396 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); | |
| 397 | }); | |
| 398 | ||
| 322 | 399 | it("removes an unreferenced local file and its pending upload", async () => { |
| 323 | 400 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 324 | 401 | onTestFinished(() => outboxLoad.mockRestore()); |
apps/mobile/src/state/use-composer-drafts.ts+18-2| ... | ... | @@ -15,8 +15,12 @@ import { Atom } from "effect/unstable/reactivity"; |
| 15 | 15 | |
| 16 | 16 | import { writeFileAtomically } from "../lib/atomic-file"; |
| 17 | 17 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 18 | import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles"; | |
| 19 | import type { DraftComposerAttachment } from "../lib/composerImages"; | |
| 18 | import { | |
| 19 | composerAttachmentFileReferenceKey, | |
| 20 | isComposerAttachmentFileRetained, | |
| 21 | retainComposerAttachmentFile, | |
| 22 | } from "../lib/composerAttachmentFiles"; | |
| 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 20 | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 21 | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 22 | 26 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; |
| ... | ... | @@ -287,6 +291,9 @@ export async function flushComposerDrafts(): Promise<void> { |
| 287 | 291 | } |
| 288 | 292 | |
| 289 | 293 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 294 | if (isComposerAttachmentFileRetained(fileUri)) { | |
| 295 | return true; | |
| 296 | } | |
| 290 | 297 | const referenceKey = composerAttachmentFileReferenceKey(fileUri); |
| 291 | 298 | const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); |
| 292 | 299 | const queuedMessages = Object.values( |
| ... | ... | @@ -436,6 +443,15 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 436 | 443 | }); |
| 437 | 444 | } |
| 438 | 445 | |
| 446 | /** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */ | |
| 447 | export function retainComposerAttachmentFileForPreview( | |
| 448 | attachment: DraftComposerFileAttachment, | |
| 449 | ): () => void { | |
| 450 | return retainComposerAttachmentFile(attachment.fileUri, () => { | |
| 451 | scheduleUnusedComposerAttachmentCleanup([attachment]); | |
| 452 | }); | |
| 453 | } | |
| 454 | ||
| 439 | 455 | function schedulePersistComposerState(): void { |
| 440 | 456 | if (persistTimer !== null) { |
| 441 | 457 | clearTimeout(persistTimer); |
apps/server/src/http.test.ts+89| ... | ... | @@ -1,13 +1,102 @@ |
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { describe } from "vite-plus/test"; |
| 3 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Path from "effect/Path"; | |
| 9 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 3 | 10 | |
| 4 | 11 | import { |
| 5 | 12 | assetResponseHeaders, |
| 13 | assetFileResponse, | |
| 6 | 14 | downloadContentDisposition, |
| 7 | 15 | isLoopbackHostname, |
| 8 | 16 | resolveDevRedirectUrl, |
| 9 | 17 | } from "./http.ts"; |
| 10 | 18 | |
| 19 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); | |
| 20 | ||
| 21 | describe("video asset byte ranges", () => { | |
| 22 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => | |
| 23 | Effect.gen(function* () { | |
| 24 | const fs = yield* FileSystem.FileSystem; | |
| 25 | const path = yield* Path.Path; | |
| 26 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); | |
| 27 | const file = path.join(directory, "clip.mp4"); | |
| 28 | yield* fs.writeFileString(file, "0123456789"); | |
| 29 | const asset = { path: file, mimeType: "video/mp4" }; | |
| 30 | for (const [header, expected, contentRange] of [ | |
| 31 | ["bytes=0-1", "01", "bytes 0-1/10"], | |
| 32 | ["bytes=4-", "456789", "bytes 4-9/10"], | |
| 33 | ["bytes=-3", "789", "bytes 7-9/10"], | |
| 34 | ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"], | |
| 35 | ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"], | |
| 36 | ] as const) { | |
| 37 | const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); | |
| 38 | expect(response.status).toBe(206); | |
| 39 | expect(response.headers.get("accept-ranges")).toBe("bytes"); | |
| 40 | expect(response.headers.get("content-range")).toBe(contentRange); | |
| 41 | expect(response.headers.get("content-length")).toBe(String(expected.length)); | |
| 42 | expect(yield* Effect.promise(() => response.text())).toBe(expected); | |
| 43 | } | |
| 44 | for (const header of [ | |
| 45 | undefined, | |
| 46 | "items=0-1", | |
| 47 | "bytes=0-1,4-5", | |
| 48 | "bytes=8-2", | |
| 49 | "bytes=-", | |
| 50 | "bytes=bad", | |
| 51 | ]) { | |
| 52 | const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); | |
| 53 | expect(response.status).toBe(200); | |
| 54 | expect(yield* Effect.promise(() => response.text())).toBe("0123456789"); | |
| 55 | } | |
| 56 | const conditional = HttpServerResponse.toWeb( | |
| 57 | yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'), | |
| 58 | ); | |
| 59 | expect(conditional.status).toBe(200); | |
| 60 | expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789"); | |
| 61 | const uppercase = HttpServerResponse.toWeb( | |
| 62 | yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"), | |
| 63 | ); | |
| 64 | expect(uppercase.status).toBe(206); | |
| 65 | expect(yield* Effect.promise(() => uppercase.text())).toBe("01"); | |
| 66 | const image = HttpServerResponse.toWeb( | |
| 67 | yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"), | |
| 68 | ); | |
| 69 | expect(image.status).toBe(200); | |
| 70 | expect(image.headers.has("accept-ranges")).toBe(false); | |
| 71 | expect(yield* Effect.promise(() => image.text())).toBe("0123456789"); | |
| 72 | }).pipe(Effect.provide(fileResponseLayer)), | |
| 73 | ); | |
| 74 | ||
| 75 | it.effect("rejects ranges outside the file, including empty files", () => | |
| 76 | Effect.gen(function* () { | |
| 77 | const fs = yield* FileSystem.FileSystem; | |
| 78 | const path = yield* Path.Path; | |
| 79 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); | |
| 80 | const file = path.join(directory, "clip.mp4"); | |
| 81 | yield* fs.writeFileString(file, "0123456789"); | |
| 82 | for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) { | |
| 83 | const response = HttpServerResponse.toWeb( | |
| 84 | yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header), | |
| 85 | ); | |
| 86 | expect(response.status).toBe(416); | |
| 87 | expect(response.headers.get("content-range")).toBe("bytes */10"); | |
| 88 | expect(yield* Effect.promise(() => response.text())).toBe(""); | |
| 89 | } | |
| 90 | yield* fs.writeFileString(file, ""); | |
| 91 | const empty = HttpServerResponse.toWeb( | |
| 92 | yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"), | |
| 93 | ); | |
| 94 | expect(empty.status).toBe(416); | |
| 95 | expect(empty.headers.get("content-range")).toBe("bytes */0"); | |
| 96 | }).pipe(Effect.provide(fileResponseLayer)), | |
| 97 | ); | |
| 98 | }); | |
| 99 | ||
| 11 | 100 | describe("http dev routing", () => { |
| 12 | 101 | it("treats localhost and loopback addresses as local", () => { |
| 13 | 102 | expect(isLoopbackHostname("127.0.0.1")).toBe(true); |
apps/server/src/http.ts+62-13| ... | ... | @@ -112,6 +112,63 @@ export function assetResponseHeaders( |
| 112 | 112 | }; |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | /** A single byte range for native video readers; unsupported range syntax uses the full file. */ | |
| 116 | function assetByteRange(header: string, size: bigint) { | |
| 117 | const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); | |
| 118 | if (!match || (!match[1] && !match[2])) return null; | |
| 119 | const first = match[1] ? BigInt(match[1]) : null; | |
| 120 | const last = match[2] ? BigInt(match[2]) : null; | |
| 121 | if (first !== null && last !== null && last < first) return null; | |
| 122 | if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) { | |
| 123 | return { _tag: "Unsatisfiable" as const }; | |
| 124 | } | |
| 125 | const start = first ?? (last! >= size ? 0n : size - last!); | |
| 126 | const end = first === null || last === null || last >= size ? size - 1n : last; | |
| 127 | return { | |
| 128 | _tag: "Range" as const, | |
| 129 | offset: start, | |
| 130 | bytesToRead: end - start + 1n, | |
| 131 | contentRange: `bytes ${start}-${end}/${size}`, | |
| 132 | }; | |
| 133 | } | |
| 134 | ||
| 135 | export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( | |
| 136 | asset: { | |
| 137 | readonly path: string; | |
| 138 | readonly download?: boolean; | |
| 139 | readonly fileName?: string; | |
| 140 | readonly mimeType?: string; | |
| 141 | }, | |
| 142 | rangeHeader?: string, | |
| 143 | ifRangeHeader?: string, | |
| 144 | ) { | |
| 145 | const headers = assetResponseHeaders(asset.path, asset); | |
| 146 | if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { | |
| 147 | headers["Accept-Ranges"] = "bytes"; | |
| 148 | // If-Range requires a matching validator. A full response is safe when we cannot validate it. | |
| 149 | if (rangeHeader && !ifRangeHeader) { | |
| 150 | const fs = yield* FileSystem.FileSystem; | |
| 151 | const info = yield* fs.stat(asset.path); | |
| 152 | const range = assetByteRange(rangeHeader, info.size); | |
| 153 | if (range?._tag === "Unsatisfiable") { | |
| 154 | return HttpServerResponse.empty({ | |
| 155 | status: 416, | |
| 156 | headers: { ...headers, "Content-Range": `bytes */${info.size}` }, | |
| 157 | }); | |
| 158 | } | |
| 159 | if (range?._tag === "Range") { | |
| 160 | return yield* HttpServerResponse.file(asset.path, { | |
| 161 | status: 206, | |
| 162 | offset: range.offset, | |
| 163 | bytesToRead: range.bytesToRead, | |
| 164 | headers: { ...headers, "Content-Range": range.contentRange }, | |
| 165 | }); | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); | |
| 170 | }); | |
| 171 | ||
| 115 | 172 | export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { |
| 116 | 173 | global: true, |
| 117 | 174 | }); |
| ... | ... | @@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add( |
| 277 | 334 | if (!asset) { |
| 278 | 335 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 279 | 336 | } |
| 280 | return yield* HttpServerResponse.file(asset.path, { | |
| 281 | status: 200, | |
| 282 | headers: assetResponseHeaders( | |
| 283 | asset.path, | |
| 284 | asset.download || asset.mimeType !== undefined | |
| 285 | ? { | |
| 286 | ...(asset.download ? { download: true } : {}), | |
| 287 | ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), | |
| 288 | ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), | |
| 289 | } | |
| 290 | : undefined, | |
| 291 | ), | |
| 292 | }).pipe( | |
| 337 | return yield* assetFileResponse( | |
| 338 | asset, | |
| 339 | request.method === "GET" ? request.headers.range : undefined, | |
| 340 | request.headers["if-range"], | |
| 341 | ).pipe( | |
| 293 | 342 | Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 294 | 343 | ); |
| 295 | 344 | }), |
apps/web/src/types.ts+3-21| ... | ... | @@ -17,6 +17,9 @@ import type { |
| 17 | 17 | EnvironmentThread, |
| 18 | 18 | EnvironmentThreadShell, |
| 19 | 19 | } from "@t3tools/client-runtime/state/shell"; |
| 20 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 21 | ||
| 22 | export { videoMimeType } from "@t3tools/shared/video"; | |
| 20 | 23 | |
| 21 | 24 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 22 | 25 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | ... | @@ -59,27 +62,6 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat |
| 59 | 62 | return attachment.type === "file"; |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | const VIDEO_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = { | |
| 63 | avi: "video/x-msvideo", | |
| 64 | m4v: "video/mp4", | |
| 65 | mkv: "video/x-matroska", | |
| 66 | mov: "video/quicktime", | |
| 67 | mp4: "video/mp4", | |
| 68 | ogv: "video/ogg", | |
| 69 | webm: "video/webm", | |
| 70 | }; | |
| 71 | ||
| 72 | export function videoMimeType( | |
| 73 | attachment: Pick<ChatFileAttachment, "name" | "mimeType">, | |
| 74 | ): string | null { | |
| 75 | const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; | |
| 76 | if (mimeType.startsWith("video/")) return mimeType; | |
| 77 | const dotIndex = attachment.name.lastIndexOf("."); | |
| 78 | return dotIndex < 0 | |
| 79 | ? null | |
| 80 | : (VIDEO_MIME_TYPE_BY_EXTENSION[attachment.name.slice(dotIndex + 1).toLowerCase()] ?? null); | |
| 81 | } | |
| 82 | ||
| 83 | 65 | export function isVideoAttachment(attachment: ChatFileAttachment): boolean { |
| 84 | 66 | return videoMimeType(attachment) !== null; |
| 85 | 67 | } |
docs/internals/mobile-navigation.md+57-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | # Mobile navigation headers | |
| 1 | # Mobile navigation | |
| 2 | 2 | |
| 3 | 3 | The iOS Home and thread routes share the root native stack in |
| 4 | 4 | [`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation |
| ... | ... | @@ -48,3 +48,59 @@ iOS `MenuView` action, including nested actions. The menu library's Fabric bridg |
| 48 | 48 | rendering `MenuView` directly. Explicit colors are preserved, and destructive |
| 49 | 49 | actions default to the theme's danger foreground color. Native stack header menus |
| 50 | 50 | use a separate implementation and do not need this workaround. |
| 51 | ||
| 52 | ## Native media presentations | |
| 53 | ||
| 54 | `PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's | |
| 55 | full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child | |
| 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 | |
| 58 | the same identifier. Identifiers must distinguish simultaneously visible attachments. | |
| 59 | This source registration does not own playback and can be reused by a future image | |
| 60 | viewer. Android uses a regular view. | |
| 61 | ||
| 62 | On iOS, video previews mount `AVPlayerViewController` temporarily inside the | |
| 63 | registered source and enter full screen through AVKit. AVKit | |
| 64 | owns that zoom, its playback controls, Close button, and interactive dismissal. | |
| 65 | Do not replace AVKit's transition with `preferredTransition`: in the iOS 27 | |
| 66 | simulator, that leaves native Close unable to exit full screen. When the source is unavailable, | |
| 67 | the player uses a standard modal presentation. Programmatic entry uses the same | |
| 68 | guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; | |
| 69 | if that selector is unavailable, the player also falls back to a standard modal. | |
| 70 | Images retain their existing viewer. | |
| 71 | ||
| 72 | 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 | |
| 74 | presentation. The URL is captured once per preview so credential refresh does not | |
| 75 | restart playback. Saving or sharing still downloads the original file. | |
| 76 | ||
| 77 | The native presentation promise completes after dismissal. Local draft previews | |
| 78 | hold their file lease until that promise settles. The iOS preview component requests | |
| 79 | native dismissal when its source screen unmounts. Playback pauses in the background. | |
| 80 | AVPlayer activates audio as playback starts. The presenter pauses and releases | |
| 81 | its own player on close, then restores the previous audio-session configuration | |
| 82 | if no other component changed it during playback. It does not deactivate the | |
| 83 | shared session, which may still serve another player or recorder. Android retains | |
| 84 | its React Native modal and Expo Video player. | |
| 85 | ||
| 86 | `shareFileFromSource` uses the same source registration to anchor UIKit's activity | |
| 87 | controller. Its promise completes when the native share flow finishes, keeping | |
| 88 | the existing attachment lease and foreground handoff active for that duration. | |
| 89 | Android uses Expo Sharing. On iOS, received and draft video attachments expose | |
| 90 | Save or share through `VideoAttachmentMenu`. The attachment supplies the source | |
| 91 | identifier, and the native share presentation inherits its appearance. AVKit's | |
| 92 | iOS playback controls do not expose a public custom-share-action API. | |
| 93 | ||
| 94 | Video attachment thumbnails use Expo Video's native frame extraction and Expo Image. | |
| 95 | Received attachments use their signed asset URL; drafts retain and resolve their local file | |
| 96 | until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels | |
| 97 | pending work; a 15-second limit prevents an unreachable source from holding up the queue. | |
| 98 | The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by | |
| 99 | environment and attachment identity rather than expiring URLs. Images still displayed keep | |
| 100 | their own references when evicted from that cache. | |
| 101 | ||
| 102 | The asset HTTP route supports single byte ranges for videos so iOS can read metadata and | |
| 103 | frames without first downloading the whole file. Normal downloads keep their full response; | |
| 104 | unsupported ranges and conditional `If-Range` requests also fall back to the full file. | |
| 105 | An older environment without range support may still show the play-card fallback. Thumbnail | |
| 106 | failure never disables playback or sharing. |
docs/user/composer.md+9-2| ... | ... | @@ -22,8 +22,15 @@ T3 Code from other apps through the system share sheet. Mobile uploads happen wh |
| 22 | 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile |
| 23 | 23 | to save it or open it in another app through the system share sheet. |
| 24 | 24 | |
| 25 | On web and desktop, select a video attachment before or after sending to play it with the browser's | |
| 26 | built-in controls. Playback depends on the video formats and codecs that the browser supports. | |
| 25 | 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. | |
| 27 | Supported videos show a thumbnail in the conversation and composer. | |
| 28 | On iOS, received videos stream from their environment as they play. Supported formats and codecs | |
| 29 | depend on the browser or device; you can save an unsupported video to open it in another app. | |
| 30 | ||
| 31 | On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the | |
| 32 | conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open | |
| 33 | the system share options. On Android, use **Save or share video** inside the preview. | |
| 27 | 34 | |
| 28 | 35 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name |
| 29 | 36 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
packages/shared/package.json+4| ... | ... | @@ -199,6 +199,10 @@ |
| 199 | 199 | "types": "./src/filePreview.ts", |
| 200 | 200 | "import": "./src/filePreview.ts" |
| 201 | 201 | }, |
| 202 | "./video": { | |
| 203 | "types": "./src/video.ts", | |
| 204 | "import": "./src/video.ts" | |
| 205 | }, | |
| 202 | 206 | "./chatList": { |
| 203 | 207 | "types": "./src/chatList.ts", |
| 204 | 208 | "import": "./src/chatList.ts" |
packages/shared/src/video.test.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { videoMimeType } from "./video.ts"; | |
| 4 | ||
| 5 | describe("videoMimeType", () => { | |
| 6 | it("recognizes a saved video with a generic picker MIME type", () => { | |
| 7 | expect(videoMimeType({ name: "Recording.MOV", mimeType: "application/octet-stream" })).toBe( | |
| 8 | "video/quicktime", | |
| 9 | ); | |
| 10 | }); | |
| 11 | ||
| 12 | it("keeps an explicit video MIME type authoritative and removes parameters", () => { | |
| 13 | expect(videoMimeType({ name: "recording.mp4", mimeType: " VIDEO/WebM; codecs=vp9 " })).toBe( | |
| 14 | "video/webm", | |
| 15 | ); | |
| 16 | }); | |
| 17 | ||
| 18 | it.each(["README", "report.pdf", "file.constructor", "file.__proto__"])( | |
| 19 | "does not mistake %s for a video", | |
| 20 | (name) => { | |
| 21 | expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBeNull(); | |
| 22 | }, | |
| 23 | ); | |
| 24 | }); |
packages/shared/src/video.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([ | |
| 2 | ["avi", "video/x-msvideo"], | |
| 3 | ["m4v", "video/mp4"], | |
| 4 | ["mkv", "video/x-matroska"], | |
| 5 | ["mov", "video/quicktime"], | |
| 6 | ["mp4", "video/mp4"], | |
| 7 | ["ogv", "video/ogg"], | |
| 8 | ["webm", "video/webm"], | |
| 9 | ]); | |
| 10 | ||
| 11 | /** Recognizes videos even when the file picker omitted their MIME type. */ | |
| 12 | export function videoMimeType(attachment: { | |
| 13 | readonly name: string; | |
| 14 | readonly mimeType: string; | |
| 15 | }): string | null { | |
| 16 | const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; | |
| 17 | if (mimeType.startsWith("video/")) return mimeType; | |
| 18 | const dotIndex = attachment.name.lastIndexOf("."); | |
| 19 | return dotIndex < 0 | |
| 20 | ? null | |
| 21 | : (VIDEO_MIME_TYPE_BY_EXTENSION.get(attachment.name.slice(dotIndex + 1).toLowerCase()) ?? null); | |
| 22 | } |
pnpm-lock.yaml+28| ... | ... | @@ -331,6 +331,9 @@ importers: |
| 331 | 331 | expo-device: |
| 332 | 332 | specifier: ~57.0.1 |
| 333 | 333 | version: 57.0.1(expo@57.0.18) |
| 334 | expo-document-picker: | |
| 335 | specifier: ~57.0.1 | |
| 336 | version: 57.0.1(expo@57.0.18) | |
| 334 | 337 | expo-file-system: |
| 335 | 338 | specifier: ~57.0.6 |
| 336 | 339 | version: 57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| ... | ... | @@ -382,6 +385,9 @@ importers: |
| 382 | 385 | expo-updates: |
| 383 | 386 | specifier: ~57.0.19 |
| 384 | 387 | version: 57.0.19(expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 388 | expo-video: | |
| 389 | specifier: ~57.0.3 | |
| 390 | version: 57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 385 | 391 | expo-web-browser: |
| 386 | 392 | specifier: ~57.0.2 |
| 387 | 393 | version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| ... | ... | @@ -6562,6 +6568,11 @@ packages: |
| 6562 | 6568 | peerDependencies: |
| 6563 | 6569 | expo: '*' |
| 6564 | 6570 | |
| 6571 | expo-document-picker@57.0.1: | |
| 6572 | resolution: {integrity: sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==} | |
| 6573 | peerDependencies: | |
| 6574 | expo: '*' | |
| 6575 | ||
| 6565 | 6576 | expo-eas-client@57.0.2: |
| 6566 | 6577 | resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} |
| 6567 | 6578 | |
| ... | ... | @@ -6731,6 +6742,13 @@ packages: |
| 6731 | 6742 | expo-dev-client: |
| 6732 | 6743 | optional: true |
| 6733 | 6744 | |
| 6745 | expo-video@57.0.3: | |
| 6746 | resolution: {integrity: sha512-Z+rLdBSzICwoHm/HUxND5fm5nfgqiB+QPWAOKxmA8ScYwvxG1UGjZ6OaayvPc3GkT4aucsbycmFO0uUv/qnIhg==} | |
| 6747 | peerDependencies: | |
| 6748 | expo: '*' | |
| 6749 | react: '*' | |
| 6750 | react-native: '*' | |
| 6751 | ||
| 6734 | 6752 | expo-web-browser@57.0.2: |
| 6735 | 6753 | resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} |
| 6736 | 6754 | peerDependencies: |
| ... | ... | @@ -16434,6 +16452,10 @@ snapshots: |
| 16434 | 16452 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) |
| 16435 | 16453 | ua-parser-js: 0.7.41 |
| 16436 | 16454 | |
| 16455 | expo-document-picker@57.0.1(expo@57.0.18): | |
| 16456 | dependencies: | |
| 16457 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) | |
| 16458 | ||
| 16437 | 16459 | expo-eas-client@57.0.2: {} |
| 16438 | 16460 | |
| 16439 | 16461 | expo-file-system@57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| ... | ... | @@ -16674,6 +16696,12 @@ snapshots: |
| 16674 | 16696 | transitivePeerDependencies: |
| 16675 | 16697 | - supports-color |
| 16676 | 16698 | |
| 16699 | expo-video@57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | |
| 16700 | dependencies: | |
| 16701 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) | |
| 16702 | react: 19.2.3 | |
| 16703 | react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | |
| 16704 | ||
| 16677 | 16705 | expo-web-browser@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16678 | 16706 | dependencies: |
| 16679 | 16707 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) |