| 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,9 +3,43 @@ import Security |
| 3 | import UIKit | 3 | import UIKit |
| 4 | 4 | ||
| 5 | public final class T3NativeControlsModule: Module { | 5 | public final class T3NativeControlsModule: Module { |
| 6 | private let presentationSources = T3PresentationSources() | ||
| 7 | private var videoPresentation: T3NativeVideoPresentation? | ||
| 8 | |||
| 6 | public func definition() -> ModuleDefinition { | 9 | public func definition() -> ModuleDefinition { |
| 7 | Name("T3NativeControls") | 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 | Function("getShowcasePairingUrl") { | 43 | Function("getShowcasePairingUrl") { |
| 10 | let arguments = ProcessInfo.processInfo.arguments | 44 | let arguments = ProcessInfo.processInfo.arguments |
| 11 | guard | 45 | guard |
| ... | @@ -101,4 +135,47 @@ public final class T3NativeControlsModule: Module { | ... | @@ -101,4 +135,47 @@ public final class T3NativeControlsModule: Module { |
| 101 | try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) | 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,6 +85,7 @@ |
| 85 | "expo-crypto": "~57.0.2", | 85 | "expo-crypto": "~57.0.2", |
| 86 | "expo-dev-client": "~57.0.16", | 86 | "expo-dev-client": "~57.0.16", |
| 87 | "expo-device": "~57.0.1", | 87 | "expo-device": "~57.0.1", |
| 88 | "expo-document-picker": "~57.0.1", | ||
| 88 | "expo-file-system": "~57.0.6", | 89 | "expo-file-system": "~57.0.6", |
| 89 | "expo-font": "~57.0.2", | 90 | "expo-font": "~57.0.2", |
| 90 | "expo-glass-effect": "~57.0.1", | 91 | "expo-glass-effect": "~57.0.1", |
| ... | @@ -102,6 +103,7 @@ | ... | @@ -102,6 +103,7 @@ |
| 102 | "expo-sqlite": "~57.0.2", | 103 | "expo-sqlite": "~57.0.2", |
| 103 | "expo-symbols": "~57.0.2", | 104 | "expo-symbols": "~57.0.2", |
| 104 | "expo-updates": "~57.0.19", | 105 | "expo-updates": "~57.0.19", |
| 106 | "expo-video": "~57.0.3", | ||
| 105 | "expo-web-browser": "~57.0.2", | 107 | "expo-web-browser": "~57.0.2", |
| 106 | "expo-widgets": "~57.0.15", | 108 | "expo-widgets": "~57.0.15", |
| 107 | "punycode": "^2.3.1", | 109 | "punycode": "^2.3.1", |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+141-34| ... | @@ -1,8 +1,12 @@ | ... | @@ -1,8 +1,12 @@ |
| 1 | import { SymbolView } from "../components/AppSymbol"; | 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 | import { AppText as Text } from "./AppText"; | 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 | export interface ComposerAttachmentStripProps { | 11 | export interface ComposerAttachmentStripProps { |
| 8 | /** Attachments to display. */ | 12 | /** Attachments to display. */ |
| ... | @@ -11,6 +15,10 @@ export interface ComposerAttachmentStripProps { | ... | @@ -11,6 +15,10 @@ export interface ComposerAttachmentStripProps { |
| 11 | readonly onRemove: (imageId: string) => void; | 15 | readonly onRemove: (imageId: string) => void; |
| 12 | /** Called when the user taps on an image thumbnail to preview it. */ | 16 | /** Called when the user taps on an image thumbnail to preview it. */ |
| 13 | readonly onPressImage?: (previewUri: string) => void; | 17 | readonly onPressImage?: (previewUri: string) => void; |
| 18 | readonly onPressVideo?: ( | ||
| 19 | attachment: DraftComposerFileAttachment, | ||
| 20 | sourceIdentifier: string, | ||
| 21 | ) => void; | ||
| 14 | /** Image thumbnail size in points. Defaults to 72. */ | 22 | /** Image thumbnail size in points. Defaults to 72. */ |
| 15 | readonly imageSize?: number; | 23 | readonly imageSize?: number; |
| 16 | /** Border radius of each image thumbnail. Defaults to 16. */ | 24 | /** Border radius of each image thumbnail. Defaults to 16. */ |
| ... | @@ -19,6 +27,130 @@ export interface ComposerAttachmentStripProps { | ... | @@ -19,6 +27,130 @@ export interface ComposerAttachmentStripProps { |
| 19 | readonly removeButtonPlacement?: "overlay" | "gutter"; | 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 | * Attachment thumbnails used by the thread composer and the new-task draft screen. | 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,38 +181,13 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 49 | paddingRight: removeButtonGutter, | 181 | paddingRight: removeButtonGutter, |
| 50 | }} | 182 | }} |
| 51 | > | 183 | > |
| 52 | {attachment.type === "image" ? ( | 184 | <ComposerAttachmentThumbnail |
| 53 | <Pressable | 185 | attachment={attachment} |
| 54 | onPress={ | 186 | size={size} |
| 55 | props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined | 187 | borderRadius={radius} |
| 56 | } | 188 | onPressImage={props.onPressImage} |
| 57 | > | 189 | onPressVideo={props.onPressVideo} |
| 58 | <Image | 190 | /> |
| 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 | )} | ||
| 84 | <Pressable | 191 | <Pressable |
| 85 | className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55" | 192 | className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55" |
| 86 | style={{ | 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,6 +35,7 @@ import { |
| 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 38 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | ||
| 38 | import { ProviderIcon } from "../../components/ProviderIcon"; | 39 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 39 | import { SymbolView } from "../../components/AppSymbol"; | 40 | import { SymbolView } from "../../components/AppSymbol"; |
| 40 | import { AppText as Text } from "../../components/AppText"; | 41 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -60,6 +61,7 @@ import { | ... | @@ -60,6 +61,7 @@ import { |
| 60 | convertPastedImagesToAttachments, | 61 | convertPastedImagesToAttachments, |
| 61 | pickComposerFiles, | 62 | pickComposerFiles, |
| 62 | pickComposerMedia, | 63 | pickComposerMedia, |
| 64 | type DraftComposerFileAttachment, | ||
| 63 | } from "../../lib/composerImages"; | 65 | } from "../../lib/composerImages"; |
| 64 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 66 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 65 | import { | 67 | import { |
| ... | @@ -164,6 +166,23 @@ export function NewTaskDraftScreen(props: { | ... | @@ -164,6 +166,23 @@ export function NewTaskDraftScreen(props: { |
| 164 | const promptInputRef = useRef<ComposerEditorHandle>(null); | 166 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 165 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 167 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 166 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 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 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | 186 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 168 | editorRef: promptInputRef, | 187 | editorRef: promptInputRef, |
| 169 | isEditorFocused: isComposerFocused, | 188 | isEditorFocused: isComposerFocused, |
| ... | @@ -1193,6 +1212,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1193,6 +1212,9 @@ export function NewTaskDraftScreen(props: { |
| 1193 | ? () => undefined | 1212 | ? () => undefined |
| 1194 | : flow.removeAttachment | 1213 | : flow.removeAttachment |
| 1195 | } | 1214 | } |
| 1215 | onPressVideo={ | ||
| 1216 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview | ||
| 1217 | } | ||
| 1196 | /> | 1218 | /> |
| 1197 | </View> | 1219 | </View> |
| 1198 | ) : null} | 1220 | ) : null} |
| ... | @@ -1296,6 +1318,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1296,6 +1318,7 @@ export function NewTaskDraftScreen(props: { |
| 1296 | </ComposerDictationToolbar> | 1318 | </ComposerDictationToolbar> |
| 1297 | </Animated.View> | 1319 | </Animated.View> |
| 1298 | </ComposerSurface> | 1320 | </ComposerSurface> |
| 1321 | <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} /> | ||
| 1299 | </View> | 1322 | </View> |
| 1300 | ); | 1323 | ); |
| 1301 | 1324 |
apps/mobile/src/features/threads/ThreadComposer.tsx+39-27| ... | @@ -19,7 +19,7 @@ import { | ... | @@ -19,7 +19,7 @@ import { |
| 19 | useState, | 19 | useState, |
| 20 | type RefObject, | 20 | type RefObject, |
| 21 | } from "react"; | 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 | import ImageViewing from "react-native-image-viewing"; | 23 | import ImageViewing from "react-native-image-viewing"; |
| 24 | import Animated, { | 24 | import Animated, { |
| 25 | FadeIn, | 25 | FadeIn, |
| ... | @@ -37,9 +37,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re | ... | @@ -37,9 +37,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re |
| 37 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 37 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 38 | 38 | ||
| 39 | import { AppText as Text } from "../../components/AppText"; | 39 | import { AppText as Text } from "../../components/AppText"; |
| 40 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 41 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 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 | import { GlassSurface } from "../../components/GlassSurface"; | 46 | import { GlassSurface } from "../../components/GlassSurface"; |
| 44 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | 47 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 45 | import { | 48 | import { |
| ... | @@ -48,7 +51,10 @@ import { | ... | @@ -48,7 +51,10 @@ import { |
| 48 | ComposerToolbarRow, | 51 | ComposerToolbarRow, |
| 49 | } from "../../components/ComposerToolbar"; | 52 | } from "../../components/ComposerToolbar"; |
| 50 | import { ProviderIcon } from "../../components/ProviderIcon"; | 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 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 53 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 59 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 54 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 60 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| ... | @@ -306,6 +312,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -306,6 +312,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 306 | const { onExpandedChange } = props; | 312 | const { onExpandedChange } = props; |
| 307 | 313 | ||
| 308 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 314 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | ||
| 309 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 310 | const showStopAction = | 317 | const showStopAction = |
| 311 | !hasContent && | 318 | !hasContent && |
| ... | @@ -368,6 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -368,6 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 368 | const onPressImage = useCallback( | 375 | const onPressImage = useCallback( |
| 369 | (uri: string) => { | 376 | (uri: string) => { |
| 370 | wasExpandedBeforePreviewRef.current = isFocused; | 377 | wasExpandedBeforePreviewRef.current = isFocused; |
| 378 | setPreviewVideo(null); | ||
| 371 | setPreviewImageUri(uri); | 379 | setPreviewImageUri(uri); |
| 372 | }, | 380 | }, |
| 373 | [isFocused], | 381 | [isFocused], |
| ... | @@ -375,10 +383,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -375,10 +383,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 375 | 383 | ||
| 376 | const closePreview = useCallback(() => { | 384 | const closePreview = useCallback(() => { |
| 377 | setPreviewImageUri(null); | 385 | setPreviewImageUri(null); |
| 386 | setPreviewVideo(null); | ||
| 378 | if (wasExpandedBeforePreviewRef.current) { | 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 | const onEditorFocusChange = props.onEditorFocusChange; | 403 | const onEditorFocusChange = props.onEditorFocusChange; |
| 384 | const handleFocus = useCallback(() => { | 404 | const handleFocus = useCallback(() => { |
| ... | @@ -600,6 +620,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -600,6 +620,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 600 | attachments={props.draftAttachments} | 620 | attachments={props.draftAttachments} |
| 601 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} | 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 602 | onPressImage={voiceInput.isBusy ? undefined : onPressImage} | 622 | onPressImage={voiceInput.isBusy ? undefined : onPressImage} |
| 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} | ||
| 603 | /> | 624 | /> |
| 604 | </Animated.View> | 625 | </Animated.View> |
| 605 | ) : null} | 626 | ) : null} |
| ... | @@ -645,27 +666,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -645,27 +666,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 645 | </Animated.View> | 666 | </Animated.View> |
| 646 | {!isExpanded && props.draftAttachments.length > 0 ? ( | 667 | {!isExpanded && props.draftAttachments.length > 0 ? ( |
| 647 | <View className="flex-row gap-1 pl-1"> | 668 | <View className="flex-row gap-1 pl-1"> |
| 648 | {props.draftAttachments.slice(0, 3).map((attachment) => | 669 | {props.draftAttachments.slice(0, 3).map((attachment) => ( |
| 649 | attachment.type === "image" ? ( | 670 | <ComposerAttachmentThumbnail |
| 650 | <Pressable | 671 | key={attachment.id} |
| 651 | key={attachment.id} | 672 | attachment={attachment} |
| 652 | onPress={() => onPressImage(attachment.previewUri)} | 673 | size={30} |
| 653 | > | 674 | borderRadius={8} |
| 654 | <Image | 675 | compact |
| 655 | source={{ uri: attachment.previewUri }} | 676 | onPressImage={onPressImage} |
| 656 | className="size-[30px] rounded-lg bg-subtle" | 677 | onPressVideo={onPressVideo} |
| 657 | resizeMode="cover" | 678 | /> |
| 658 | /> | 679 | ))} |
| 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.length > 3 ? ( | 680 | {props.draftAttachments.length > 3 ? ( |
| 670 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> | 681 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> |
| 671 | <Text className="text-foreground-muted text-2xs font-t3-bold"> | 682 | <Text className="text-foreground-muted text-2xs font-t3-bold"> |
| ... | @@ -807,6 +818,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -807,6 +818,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 807 | ) : null} | 818 | ) : null} |
| 808 | </Animated.View> | 819 | </Animated.View> |
| 809 | 820 | ||
| 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> | ||
| 810 | <ImageViewing | 822 | <ImageViewing |
| 811 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | 823 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 812 | imageIndex={0} | 824 | imageIndex={0} |
apps/mobile/src/features/threads/ThreadFeed.tsx+104-42| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | splitCodexArtifactTemplateMarkdown, | 23 | splitCodexArtifactTemplateMarkdown, |
| 24 | } from "@t3tools/client-runtime/codex-markdown-directives"; | 24 | } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 25 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 25 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 26 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 26 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | 27 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 27 | import { HeaderHeightContext } from "@react-navigation/elements"; | 28 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 28 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; | 29 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| ... | @@ -88,6 +89,8 @@ import { | ... | @@ -88,6 +89,8 @@ import { |
| 88 | } from "../../native/SelectableMarkdownText"; | 89 | } from "../../native/SelectableMarkdownText"; |
| 89 | 90 | ||
| 90 | import { AppText as Text } from "../../components/AppText"; | 91 | import { AppText as Text } from "../../components/AppText"; |
| 92 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | ||
| 93 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; | ||
| 91 | import { CopyTextButton } from "../../components/CopyTextButton"; | 94 | import { CopyTextButton } from "../../components/CopyTextButton"; |
| 92 | import { | 95 | import { |
| 93 | parseReviewCommentMessageSegments, | 96 | parseReviewCommentMessageSegments, |
| ... | @@ -246,12 +249,25 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt | ... | @@ -246,12 +249,25 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt |
| 246 | function MessageAttachmentFile(props: { | 249 | function MessageAttachmentFile(props: { |
| 247 | readonly environmentId: EnvironmentId; | 250 | readonly environmentId: EnvironmentId; |
| 248 | readonly attachment: ChatFileAttachment; | 251 | readonly attachment: ChatFileAttachment; |
| 252 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | ||
| 249 | }) { | 253 | }) { |
| 250 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 254 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 251 | reportFailure: false, | 255 | reportFailure: false, |
| 252 | }); | 256 | }); |
| 253 | const preparedConnection = usePreparedConnection(props.environmentId); | 257 | const preparedConnection = usePreparedConnection(props.environmentId); |
| 254 | const { attachment } = props; | 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 | const httpBaseUrl = Option.isSome(preparedConnection) | 271 | const httpBaseUrl = Option.isSome(preparedConnection) |
| 256 | ? preparedConnection.value.httpBaseUrl | 272 | ? preparedConnection.value.httpBaseUrl |
| 257 | : null; | 273 | : null; |
| ... | @@ -268,6 +284,69 @@ function MessageAttachmentFile(props: { | ... | @@ -268,6 +284,69 @@ function MessageAttachmentFile(props: { |
| 268 | }, [props.environmentId, attachment.id, httpBaseUrl]), | 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 | return ( | 350 | return ( |
| 272 | <Pressable | 351 | <Pressable |
| 273 | accessibilityRole="button" | 352 | accessibilityRole="button" |
| ... | @@ -275,48 +354,7 @@ function MessageAttachmentFile(props: { | ... | @@ -275,48 +354,7 @@ function MessageAttachmentFile(props: { |
| 275 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | 354 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} |
| 276 | disabled={opening || httpBaseUrl === null} | 355 | disabled={opening || httpBaseUrl === null} |
| 277 | className="flex-row items-center gap-2 py-1" | 356 | className="flex-row items-center gap-2 py-1" |
| 278 | onPress={() => { | 357 | onPress={() => shareFile()} |
| 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 | }} | ||
| 320 | > | 358 | > |
| 321 | {opening ? ( | 359 | {opening ? ( |
| 322 | <ActivityIndicator size="small" /> | 360 | <ActivityIndicator size="small" /> |
| ... | @@ -1227,6 +1265,7 @@ function renderFeedEntry( | ... | @@ -1227,6 +1265,7 @@ function renderFeedEntry( |
| 1227 | readonly onToggleWorkRow: (rowId: string) => void; | 1265 | readonly onToggleWorkRow: (rowId: string) => void; |
| 1228 | readonly onToggleTurnFold: (turnId: TurnId) => void; | 1266 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1229 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | 1267 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 1268 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | ||
| 1230 | readonly onMarkdownLinkPress: (href: string) => void; | 1269 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1231 | readonly renderMarkdownImage: MarkdownImageRenderer; | 1270 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| 1232 | readonly iconSubtleColor: string | import("react-native").ColorValue; | 1271 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| ... | @@ -1344,6 +1383,7 @@ function renderFeedEntry( | ... | @@ -1344,6 +1383,7 @@ function renderFeedEntry( |
| 1344 | key={attachment.id} | 1383 | key={attachment.id} |
| 1345 | environmentId={props.environmentId} | 1384 | environmentId={props.environmentId} |
| 1346 | attachment={attachment} | 1385 | attachment={attachment} |
| 1386 | onPressVideo={props.onPressVideo} | ||
| 1347 | /> | 1387 | /> |
| 1348 | ) : ( | 1388 | ) : ( |
| 1349 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> | 1389 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> |
| ... | @@ -1404,6 +1444,7 @@ function renderFeedEntry( | ... | @@ -1404,6 +1444,7 @@ function renderFeedEntry( |
| 1404 | key={attachment.id} | 1444 | key={attachment.id} |
| 1405 | environmentId={props.environmentId} | 1445 | environmentId={props.environmentId} |
| 1406 | attachment={attachment} | 1446 | attachment={attachment} |
| 1447 | onPressVideo={props.onPressVideo} | ||
| 1407 | /> | 1448 | /> |
| 1408 | ) : ( | 1449 | ) : ( |
| 1409 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> | 1450 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> |
| ... | @@ -1769,6 +1810,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1769,6 +1810,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1769 | uri: string; | 1810 | uri: string; |
| 1770 | headers?: Record<string, string>; | 1811 | headers?: Record<string, string>; |
| 1771 | } | null>(null); | 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 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | 1817 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1773 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ | 1818 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| 1774 | viewportWidth, | 1819 | viewportWidth, |
| ... | @@ -2243,6 +2288,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2243,6 +2288,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2243 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | 2288 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { |
| 2244 | setExpandedImage({ uri, headers }); | 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 | // Rows whose height is known before they ever render. Without this, every | 2306 | // Rows whose height is known before they ever render. Without this, every |
| 2248 | // row above the viewport is assumed to be estimatedItemSize tall, and | 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,6 +2351,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2292 | onToggleWorkRow, | 2351 | onToggleWorkRow, |
| 2293 | onToggleTurnFold, | 2352 | onToggleTurnFold, |
| 2294 | onPressImage, | 2353 | onPressImage, |
| 2354 | onPressVideo, | ||
| 2295 | onMarkdownLinkPress, | 2355 | onMarkdownLinkPress, |
| 2296 | renderMarkdownImage, | 2356 | renderMarkdownImage, |
| 2297 | iconSubtleColor, | 2357 | iconSubtleColor, |
| ... | @@ -2320,6 +2380,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2320,6 +2380,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2320 | onCopyWorkRow, | 2380 | onCopyWorkRow, |
| 2321 | onMarkdownLinkPress, | 2381 | onMarkdownLinkPress, |
| 2322 | onPressImage, | 2382 | onPressImage, |
| 2383 | onPressVideo, | ||
| 2323 | onToggleTurnFold, | 2384 | onToggleTurnFold, |
| 2324 | onToggleWorkGroup, | 2385 | onToggleWorkGroup, |
| 2325 | onToggleWorkRow, | 2386 | onToggleWorkRow, |
| ... | @@ -2503,6 +2564,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2503,6 +2564,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2503 | ) : null} | 2564 | ) : null} |
| 2504 | </View> | 2565 | </View> |
| 2505 | 2566 | ||
| 2567 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> | ||
| 2506 | <ImageViewing | 2568 | <ImageViewing |
| 2507 | images={ | 2569 | images={ |
| 2508 | expandedImage | 2570 | expandedImage |
apps/mobile/src/lib/attachmentDownload.test.ts+133-3| ... | @@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => ({ |
| 4 | directories: new Set<string>(), | 4 | directories: new Set<string>(), |
| 5 | deleted: vi.fn(), | 5 | deleted: vi.fn(), |
| 6 | download: vi.fn(), | 6 | download: vi.fn(), |
| 7 | copy: vi.fn(), | ||
| 7 | share: vi.fn(), | 8 | share: vi.fn(), |
| 9 | shareFromSource: vi.fn(), | ||
| 8 | available: vi.fn(), | 10 | available: vi.fn(), |
| 9 | uuid: vi.fn(), | 11 | uuid: vi.fn(), |
| 10 | })); | 12 | })); |
| ... | @@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => { | ... | @@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => { |
| 46 | static downloadFileAsync = mocks.download; | 48 | static downloadFileAsync = mocks.download; |
| 47 | readonly uri: string; | 49 | readonly uri: string; |
| 48 | 50 | ||
| 49 | constructor(directory: Directory, name: string) { | 51 | constructor(source: Directory | string, name?: string) { |
| 50 | this.uri = `${directory.uri}/${encodeURIComponent(name)}`; | 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,8 +66,13 @@ vi.mock("expo-sharing", () => ({ |
| 60 | })); | 66 | })); |
| 61 | 67 | ||
| 62 | vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); | 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 | import { isForegroundHandoffActive } from "./foreground-handoff"; | 76 | import { isForegroundHandoffActive } from "./foreground-handoff"; |
| 66 | 77 | ||
| 67 | const NOW = 1_787_990_400_000; | 78 | const NOW = 1_787_990_400_000; |
| ... | @@ -76,11 +87,15 @@ beforeEach(() => { | ... | @@ -76,11 +87,15 @@ beforeEach(() => { |
| 76 | mocks.directories.clear(); | 87 | mocks.directories.clear(); |
| 77 | mocks.deleted.mockReset(); | 88 | mocks.deleted.mockReset(); |
| 78 | mocks.download.mockReset(); | 89 | mocks.download.mockReset(); |
| 90 | mocks.copy.mockReset(); | ||
| 79 | mocks.share.mockReset(); | 91 | mocks.share.mockReset(); |
| 92 | mocks.shareFromSource.mockReset(); | ||
| 80 | mocks.available.mockReset(); | 93 | mocks.available.mockReset(); |
| 81 | mocks.uuid.mockReset(); | 94 | mocks.uuid.mockReset(); |
| 82 | mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); | 95 | mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); |
| 96 | mocks.copy.mockResolvedValue(undefined); | ||
| 83 | mocks.share.mockResolvedValue(undefined); | 97 | mocks.share.mockResolvedValue(undefined); |
| 98 | mocks.shareFromSource.mockResolvedValue(undefined); | ||
| 84 | mocks.available.mockResolvedValue(true); | 99 | mocks.available.mockResolvedValue(true); |
| 85 | let sequence = 0; | 100 | let sequence = 0; |
| 86 | mocks.uuid.mockImplementation( | 101 | mocks.uuid.mockImplementation( |
| ... | @@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => { | ... | @@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => { |
| 268 | await first; | 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,5 +1,6 @@ |
| 1 | import type { ChatFileAttachment } from "@t3tools/contracts"; | 1 | import type { ChatFileAttachment } from "@t3tools/contracts"; |
| 2 | import type { Directory } from "expo-file-system"; | 2 | import type { Directory } from "expo-file-system"; |
| 3 | import type { SharingOptions } from "expo-sharing"; | ||
| 3 | 4 | ||
| 4 | import { beginForegroundHandoff } from "./foreground-handoff"; | 5 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 5 | import { uuidv4 } from "./uuid"; | 6 | import { uuidv4 } from "./uuid"; |
| ... | @@ -52,23 +53,27 @@ function removeDownloadDirectory(directory: Directory): void { | ... | @@ -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 | type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">; |
| 56 | export async function downloadAndShareAttachment(input: { | 57 | |
| 57 | readonly url: string; | 58 | export interface AttachmentPreviewFile { |
| 58 | readonly attachment: Pick<ChatFileAttachment, "name" | "mimeType">; | 59 | readonly uri: string; |
| 59 | readonly signal: AbortSignal; | 60 | readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>; |
| 60 | }): Promise<void> { | 61 | readonly dispose: () => void; |
| 61 | const [{ Directory, File, Paths }, Sharing] = await Promise.all([ | 62 | } |
| 62 | import("expo-file-system"), | 63 | |
| 63 | import("expo-sharing"), | 64 | async function availableSharing(signal: AbortSignal) { |
| 64 | ]); | 65 | if (signal.aborted) return null; |
| 65 | if (input.signal.aborted) return; | 66 | const Sharing = await import("expo-sharing"); |
| 66 | const canShare = await Sharing.isAvailableAsync(); | 67 | const canShare = await Sharing.isAvailableAsync(); |
| 67 | if (input.signal.aborted) return; | 68 | if (signal.aborted) return null; |
| 68 | if (!canShare) { | 69 | if (!canShare) { |
| 69 | throw new Error("Saving and sharing files is unavailable on this device."); | 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 | const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); | 77 | const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); |
| 73 | cache.create({ idempotent: true, intermediates: true }); | 78 | cache.create({ idempotent: true, intermediates: true }); |
| 74 | const now = Date.now(); | 79 | const now = Date.now(); |
| ... | @@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: { | ... | @@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: { |
| 89 | } | 94 | } |
| 90 | 95 | ||
| 91 | const directory = new Directory(cache, `${now}-${uuidv4()}`); | 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 | activeDirectories.add(directory.uri); | 105 | activeDirectories.add(directory.uri); |
| 106 | let disposed = false; | ||
| 93 | let shared = false; | 107 | let shared = false; |
| 94 | let openingShareSheet = false; | 108 | let sharing = false; |
| 95 | try { | 109 | const release = () => { |
| 96 | directory.create(); | 110 | if (!disposed || sharing) return; |
| 97 | const destination = new File(directory, downloadFileName(input.attachment.name)); | 111 | activeDirectories.delete(directory.uri); |
| 98 | const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal }); | 112 | // A receiver can still be reading after Android's chooser returns. |
| 99 | if (input.signal.aborted) return; | 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; | 157 | /** The caller owns this cached file until disposal, unless it has been shared with another app. */ |
| 102 | const endHandoff = beginForegroundHandoff(); | 158 | export async function downloadAttachmentForPreview(input: { |
| 103 | try { | 159 | readonly url: string; |
| 104 | await Sharing.shareAsync(file.uri, { | 160 | readonly attachment: AttachmentFileMetadata; |
| 105 | mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", | 161 | readonly signal: AbortSignal; |
| 106 | dialogTitle: input.attachment.name, | 162 | }): Promise<AttachmentPreviewFile | null> { |
| 107 | }); | 163 | if (input.signal.aborted) return null; |
| 108 | shared = true; | 164 | const { File } = await import("expo-file-system"); |
| 109 | } finally { | 165 | const cached = await createCachedAttachmentFile(input.attachment); |
| 110 | endHandoff(); | 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 | } catch (cause) { | 177 | } catch (cause) { |
| 113 | if (input.signal.aborted) return; | 178 | // Android may leave a partial file after a failed or interrupted request. |
| 114 | throw new Error( | 179 | cached.preview.dispose(); |
| 115 | openingShareSheet | 180 | if (input.signal.aborted) return null; |
| 116 | ? "Could not open the share sheet. Try again." | 181 | throw new Error("Could not download the attachment. Check the connection and try again.", { |
| 117 | : "Could not download the attachment. Check the connection and try again.", | 182 | cause, |
| 118 | { cause }, | 183 | }); |
| 119 | ); | 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 | } finally { | 199 | } finally { |
| 121 | activeDirectories.delete(directory.uri); | 200 | file.dispose(); |
| 122 | // A receiver can still be reading after Android's chooser returns. | 201 | } |
| 123 | // Successful exports expire on a later open; partial downloads do not. | 202 | } |
| 124 | if (!shared) { | 203 | |
| 125 | removeDownloadDirectory(directory); | 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 +6,7 @@ const IOS_DOCUMENTS_PATH = new RegExp( |
| 6 | `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, | 6 | `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, |
| 7 | "i", | 7 | "i", |
| 8 | ); | 8 | ); |
| 9 | const retainedFiles = new Map<string, number>(); | ||
| 9 | 10 | ||
| 10 | function fileUriPath(uri: string): string | null { | 11 | function fileUriPath(uri: string): string | null { |
| 11 | try { | 12 | try { |
| ... | @@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string { | ... | @@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string { |
| 52 | return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; | 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 | * Resolves only our saved attachment copies. iOS preserves Documents on updates | 81 | * Resolves only our saved attachment copies. iOS preserves Documents on updates |
| 57 | * but can change its container UUID. Picker and open-in-place source URIs must | 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,8 +23,6 @@ vi.mock("expo-file-system", () => { |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | class File { | 25 | class File { |
| 26 | static pickFileAsync = mocks.pickFile; | ||
| 27 | |||
| 28 | readonly uri: string; | 26 | readonly uri: string; |
| 29 | 27 | ||
| 30 | constructor(source: string | Directory, name?: string) { | 28 | constructor(source: string | Directory, name?: string) { |
| ... | @@ -75,6 +73,7 @@ vi.mock("expo-file-system", () => { | ... | @@ -75,6 +73,7 @@ vi.mock("expo-file-system", () => { |
| 75 | }); | 73 | }); |
| 76 | 74 | ||
| 77 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); | 75 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); |
| 76 | vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); | ||
| 78 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); | 77 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); |
| 79 | 78 | ||
| 80 | import { | 79 | import { |
| ... | @@ -85,6 +84,7 @@ import { | ... | @@ -85,6 +84,7 @@ import { |
| 85 | removePersistedComposerAttachmentFile, | 84 | removePersistedComposerAttachmentFile, |
| 86 | } from "./composerImages"; | 85 | } from "./composerImages"; |
| 87 | import { isForegroundHandoffActive } from "./foreground-handoff"; | 86 | import { isForegroundHandoffActive } from "./foreground-handoff"; |
| 87 | import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; | ||
| 88 | 88 | ||
| 89 | describe("composer file attachments", () => { | 89 | describe("composer file attachments", () => { |
| 90 | beforeEach(() => { | 90 | beforeEach(() => { |
| ... | @@ -274,11 +274,11 @@ describe("composer file attachments", () => { | ... | @@ -274,11 +274,11 @@ describe("composer file attachments", () => { |
| 274 | it("copies picked files into app-owned storage without loading their contents", async () => { | 274 | it("copies picked files into app-owned storage without loading their contents", async () => { |
| 275 | mocks.pickFile.mockResolvedValue({ | 275 | mocks.pickFile.mockResolvedValue({ |
| 276 | canceled: false, | 276 | canceled: false, |
| 277 | result: [ | 277 | assets: [ |
| 278 | { | 278 | { |
| 279 | uri: "file:///downloads/report.pdf", | 279 | uri: "file:///downloads/report.pdf", |
| 280 | name: "report.pdf", | 280 | name: "report.pdf", |
| 281 | type: "application/pdf", | 281 | mimeType: "application/pdf", |
| 282 | size: 42, | 282 | size: 42, |
| 283 | }, | 283 | }, |
| 284 | ], | 284 | ], |
| ... | @@ -303,14 +303,120 @@ describe("composer file attachments", () => { | ... | @@ -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 | it("falls back to a usable name when the picker reports a blank one", async () => { | 412 | it("falls back to a usable name when the picker reports a blank one", async () => { |
| 307 | mocks.pickFile.mockResolvedValue({ | 413 | mocks.pickFile.mockResolvedValue({ |
| 308 | canceled: false, | 414 | canceled: false, |
| 309 | result: [ | 415 | assets: [ |
| 310 | { | 416 | { |
| 311 | uri: "file:///downloads/unnamed", | 417 | uri: "file:///downloads/unnamed", |
| 312 | name: " ", | 418 | name: " ", |
| 313 | type: "application/pdf", | 419 | mimeType: "application/pdf", |
| 314 | size: 42, | 420 | size: 42, |
| 315 | }, | 421 | }, |
| 316 | ], | 422 | ], |
| ... | @@ -325,11 +431,11 @@ describe("composer file attachments", () => { | ... | @@ -325,11 +431,11 @@ describe("composer file attachments", () => { |
| 325 | it("rejects files that exceed the environment's advertised upload limit", async () => { | 431 | it("rejects files that exceed the environment's advertised upload limit", async () => { |
| 326 | mocks.pickFile.mockResolvedValue({ | 432 | mocks.pickFile.mockResolvedValue({ |
| 327 | canceled: false, | 433 | canceled: false, |
| 328 | result: [ | 434 | assets: [ |
| 329 | { | 435 | { |
| 330 | uri: "file:///downloads/archive.zip", | 436 | uri: "file:///downloads/archive.zip", |
| 331 | name: "archive.zip", | 437 | name: "archive.zip", |
| 332 | type: "application/zip", | 438 | mimeType: "application/zip", |
| 333 | size: 2 * 1024 * 1024, | 439 | size: 2 * 1024 * 1024, |
| 334 | }, | 440 | }, |
| 335 | ], | 441 | ], |
| ... | @@ -345,11 +451,11 @@ describe("composer file attachments", () => { | ... | @@ -345,11 +451,11 @@ describe("composer file attachments", () => { |
| 345 | it("never accepts files above the 50 MB contract limit", async () => { | 451 | it("never accepts files above the 50 MB contract limit", async () => { |
| 346 | mocks.pickFile.mockResolvedValue({ | 452 | mocks.pickFile.mockResolvedValue({ |
| 347 | canceled: false, | 453 | canceled: false, |
| 348 | result: [ | 454 | assets: [ |
| 349 | { | 455 | { |
| 350 | uri: "file:///downloads/archive.zip", | 456 | uri: "file:///downloads/archive.zip", |
| 351 | name: "archive.zip", | 457 | name: "archive.zip", |
| 352 | type: "application/zip", | 458 | mimeType: "application/zip", |
| 353 | size: 51 * 1024 * 1024, | 459 | size: 51 * 1024 * 1024, |
| 354 | }, | 460 | }, |
| 355 | ], | 461 | ], |
| ... | @@ -366,11 +472,11 @@ describe("composer file attachments", () => { | ... | @@ -366,11 +472,11 @@ describe("composer file attachments", () => { |
| 366 | it("rejects a file that grew after the picker reported its size", async () => { | 472 | it("rejects a file that grew after the picker reported its size", async () => { |
| 367 | mocks.pickFile.mockResolvedValue({ | 473 | mocks.pickFile.mockResolvedValue({ |
| 368 | canceled: false, | 474 | canceled: false, |
| 369 | result: [ | 475 | assets: [ |
| 370 | { | 476 | { |
| 371 | uri: "file:///downloads/archive.zip", | 477 | uri: "file:///downloads/archive.zip", |
| 372 | name: "archive.zip", | 478 | name: "archive.zip", |
| 373 | type: "application/zip", | 479 | mimeType: "application/zip", |
| 374 | size: 42, | 480 | size: 42, |
| 375 | }, | 481 | }, |
| 376 | ], | 482 | ], |
| ... | @@ -434,11 +540,11 @@ describe("composer file attachments", () => { | ... | @@ -434,11 +540,11 @@ describe("composer file attachments", () => { |
| 434 | mocks.size.mockReturnValue(0); | 540 | mocks.size.mockReturnValue(0); |
| 435 | mocks.pickFile.mockResolvedValue({ | 541 | mocks.pickFile.mockResolvedValue({ |
| 436 | canceled: false, | 542 | canceled: false, |
| 437 | result: [ | 543 | assets: [ |
| 438 | { | 544 | { |
| 439 | uri: "file:///downloads/empty.txt", | 545 | uri: "file:///downloads/empty.txt", |
| 440 | name: "empty.txt", | 546 | name: "empty.txt", |
| 441 | type: "text/plain", | 547 | mimeType: "text/plain", |
| 442 | size: 0, | 548 | size: 0, |
| 443 | }, | 549 | }, |
| 444 | ], | 550 | ], |
| ... | @@ -450,7 +556,7 @@ describe("composer file attachments", () => { | ... | @@ -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 | const reader = { | 560 | const reader = { |
| 455 | readBytes: vi | 561 | readBytes: vi |
| 456 | .fn() | 562 | .fn() |
| ... | @@ -463,12 +569,12 @@ describe("composer file attachments", () => { | ... | @@ -463,12 +569,12 @@ describe("composer file attachments", () => { |
| 463 | mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); | 569 | mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); |
| 464 | mocks.pickFile.mockResolvedValue({ | 570 | mocks.pickFile.mockResolvedValue({ |
| 465 | canceled: false, | 571 | canceled: false, |
| 466 | result: [ | 572 | assets: [ |
| 467 | { | 573 | { |
| 468 | uri: "content://shared/report", | 574 | uri: "content://shared/report", |
| 469 | name: "report.pdf", | 575 | name: "report.pdf", |
| 470 | type: "application/pdf", | 576 | mimeType: "application/pdf", |
| 471 | size: 0, | 577 | size, |
| 472 | }, | 578 | }, |
| 473 | ], | 579 | ], |
| 474 | }); | 580 | }); |
| ... | @@ -491,17 +597,17 @@ describe("composer file attachments", () => { | ... | @@ -491,17 +597,17 @@ describe("composer file attachments", () => { |
| 491 | it("uses the remaining slot for the first valid file after an oversized selection", async () => { | 597 | it("uses the remaining slot for the first valid file after an oversized selection", async () => { |
| 492 | mocks.pickFile.mockResolvedValue({ | 598 | mocks.pickFile.mockResolvedValue({ |
| 493 | canceled: false, | 599 | canceled: false, |
| 494 | result: [ | 600 | assets: [ |
| 495 | { | 601 | { |
| 496 | uri: "file:///downloads/huge.zip", | 602 | uri: "file:///downloads/huge.zip", |
| 497 | name: "huge.zip", | 603 | name: "huge.zip", |
| 498 | type: "application/zip", | 604 | mimeType: "application/zip", |
| 499 | size: 2 * 1024 * 1024, | 605 | size: 2 * 1024 * 1024, |
| 500 | }, | 606 | }, |
| 501 | { | 607 | { |
| 502 | uri: "file:///downloads/report.pdf", | 608 | uri: "file:///downloads/report.pdf", |
| 503 | name: "report.pdf", | 609 | name: "report.pdf", |
| 504 | type: "application/pdf", | 610 | mimeType: "application/pdf", |
| 505 | size: 42, | 611 | size: 42, |
| 506 | }, | 612 | }, |
| 507 | ], | 613 | ], |
| ... | @@ -557,6 +663,26 @@ describe("composer file attachments", () => { | ... | @@ -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 | it("copies an open-in-place source from its actual container without rebasing it", async () => { | 686 | it("copies an open-in-place source from its actual container without rebasing it", async () => { |
| 561 | const sourceUri = | 687 | const sourceUri = |
| 562 | "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf"; | 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 +10,11 @@ import { |
| 10 | type EnvironmentId, | 10 | type EnvironmentId, |
| 11 | type UploadChatImageAttachment, | 11 | type UploadChatImageAttachment, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import type { PickMultipleFilesResult } from "expo-file-system"; | 13 | import type { DocumentPickerResult } from "expo-document-picker"; |
| 14 | import { estimateBase64ByteSize } from "./base64"; | 14 | import { estimateBase64ByteSize } from "./base64"; |
| 15 | import { | 15 | import { |
| 16 | COMPOSER_ATTACHMENT_DIRECTORY, | 16 | COMPOSER_ATTACHMENT_DIRECTORY, |
| 17 | isComposerAttachmentFileRetained, | ||
| 17 | resolveOwnedComposerAttachmentFileUri, | 18 | resolveOwnedComposerAttachmentFileUri, |
| 18 | } from "./composerAttachmentFiles"; | 19 | } from "./composerAttachmentFiles"; |
| 19 | import { beginForegroundHandoff } from "./foreground-handoff"; | 20 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| ... | @@ -145,7 +146,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis | ... | @@ -145,7 +146,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis |
| 145 | try { | 146 | try { |
| 146 | const { File, Paths } = await import("expo-file-system"); | 147 | const { File, Paths } = await import("expo-file-system"); |
| 147 | const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); | 148 | const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); |
| 148 | if (ownedUri === null) { | 149 | if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) { |
| 149 | return; | 150 | return; |
| 150 | } | 151 | } |
| 151 | const file = new File(ownedUri); | 152 | const file = new File(ownedUri); |
| ... | @@ -206,11 +207,18 @@ export async function pickComposerFiles(input: { | ... | @@ -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 | const endHandoff = beginForegroundHandoff(); | 211 | const endHandoff = beginForegroundHandoff(); |
| 211 | let result: PickMultipleFilesResult; | 212 | let result: DocumentPickerResult; |
| 212 | try { | 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 | } finally { | 222 | } finally { |
| 215 | endHandoff(); | 223 | endHandoff(); |
| 216 | } | 224 | } |
| ... | @@ -224,7 +232,7 @@ export async function pickComposerFiles(input: { | ... | @@ -224,7 +232,7 @@ export async function pickComposerFiles(input: { |
| 224 | const attachments: DraftComposerFileAttachment[] = []; | 232 | const attachments: DraftComposerFileAttachment[] = []; |
| 225 | let error: string | null = null; | 233 | let error: string | null = null; |
| 226 | let exceededAttachmentLimit = false; | 234 | let exceededAttachmentLimit = false; |
| 227 | for (const file of result.result) { | 235 | for (const file of result.assets) { |
| 228 | if (attachments.length >= remainingSlots) { | 236 | if (attachments.length >= remainingSlots) { |
| 229 | exceededAttachmentLimit = true; | 237 | exceededAttachmentLimit = true; |
| 230 | break; | 238 | break; |
| ... | @@ -238,7 +246,7 @@ export async function pickComposerFiles(input: { | ... | @@ -238,7 +246,7 @@ export async function pickComposerFiles(input: { |
| 238 | await createComposerFileAttachment({ | 246 | await createComposerFileAttachment({ |
| 239 | uri: file.uri, | 247 | uri: file.uri, |
| 240 | name, | 248 | name, |
| 241 | mimeType: file.type || "application/octet-stream", | 249 | mimeType: file.mimeType || "application/octet-stream", |
| 242 | sizeBytes: file.size ?? null, | 250 | sizeBytes: file.size ?? null, |
| 243 | maxBytes, | 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,6 +134,7 @@ import { |
| 134 | releaseUnusedComposerAttachmentFiles, | 134 | releaseUnusedComposerAttachmentFiles, |
| 135 | removeComposerDraftsForEnvironment, | 135 | removeComposerDraftsForEnvironment, |
| 136 | resetComposerDraftsLoadState, | 136 | resetComposerDraftsLoadState, |
| 137 | retainComposerAttachmentFileForPreview, | ||
| 137 | restoreComposerDraftSnapshotState, | 138 | restoreComposerDraftSnapshotState, |
| 138 | setComposerDraftText, | 139 | setComposerDraftText, |
| 139 | setStickyComposerModelSelection, | 140 | setStickyComposerModelSelection, |
| ... | @@ -319,6 +320,82 @@ describe("mobile composer drafts", () => { | ... | @@ -319,6 +320,82 @@ describe("mobile composer drafts", () => { |
| 319 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | 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 | it("removes an unreferenced local file and its pending upload", async () => { | 399 | it("removes an unreferenced local file and its pending upload", async () => { |
| 323 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 400 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 324 | onTestFinished(() => outboxLoad.mockRestore()); | 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,8 +15,12 @@ import { Atom } from "effect/unstable/reactivity"; |
| 15 | 15 | ||
| 16 | import { writeFileAtomically } from "../lib/atomic-file"; | 16 | import { writeFileAtomically } from "../lib/atomic-file"; |
| 17 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; | 17 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 18 | import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles"; | 18 | import { |
| 19 | import type { DraftComposerAttachment } from "../lib/composerImages"; | 19 | composerAttachmentFileReferenceKey, |
| 20 | isComposerAttachmentFileRetained, | ||
| 21 | retainComposerAttachmentFile, | ||
| 22 | } from "../lib/composerAttachmentFiles"; | ||
| 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | ||
| 20 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 21 | import { appAtomRegistry } from "./atom-registry"; | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 22 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; | 26 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; |
| ... | @@ -287,6 +291,9 @@ export async function flushComposerDrafts(): Promise<void> { | ... | @@ -287,6 +291,9 @@ export async function flushComposerDrafts(): Promise<void> { |
| 287 | } | 291 | } |
| 288 | 292 | ||
| 289 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { | 293 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 294 | if (isComposerAttachmentFileRetained(fileUri)) { | ||
| 295 | return true; | ||
| 296 | } | ||
| 290 | const referenceKey = composerAttachmentFileReferenceKey(fileUri); | 297 | const referenceKey = composerAttachmentFileReferenceKey(fileUri); |
| 291 | const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); | 298 | const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); |
| 292 | const queuedMessages = Object.values( | 299 | const queuedMessages = Object.values( |
| ... | @@ -436,6 +443,15 @@ export function scheduleUnusedComposerAttachmentCleanup( | ... | @@ -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 | function schedulePersistComposerState(): void { | 455 | function schedulePersistComposerState(): void { |
| 440 | if (persistTimer !== null) { | 456 | if (persistTimer !== null) { |
| 441 | clearTimeout(persistTimer); | 457 | clearTimeout(persistTimer); |
apps/server/src/http.test.ts+89| ... | @@ -1,13 +1,102 @@ | ... | @@ -1,13 +1,102 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { describe } from "vite-plus/test"; | 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 | import { | 11 | import { |
| 5 | assetResponseHeaders, | 12 | assetResponseHeaders, |
| 13 | assetFileResponse, | ||
| 6 | downloadContentDisposition, | 14 | downloadContentDisposition, |
| 7 | isLoopbackHostname, | 15 | isLoopbackHostname, |
| 8 | resolveDevRedirectUrl, | 16 | resolveDevRedirectUrl, |
| 9 | } from "./http.ts"; | 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 | describe("http dev routing", () => { | 100 | describe("http dev routing", () => { |
| 12 | it("treats localhost and loopback addresses as local", () => { | 101 | it("treats localhost and loopback addresses as local", () => { |
| 13 | expect(isLoopbackHostname("127.0.0.1")).toBe(true); | 102 | expect(isLoopbackHostname("127.0.0.1")).toBe(true); |
apps/server/src/http.ts+62-13| ... | @@ -112,6 +112,63 @@ export function assetResponseHeaders( | ... | @@ -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 | export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { | 172 | export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { |
| 116 | global: true, | 173 | global: true, |
| 117 | }); | 174 | }); |
| ... | @@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add( | ... | @@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add( |
| 277 | if (!asset) { | 334 | if (!asset) { |
| 278 | return HttpServerResponse.text("Not Found", { status: 404 }); | 335 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 279 | } | 336 | } |
| 280 | return yield* HttpServerResponse.file(asset.path, { | 337 | return yield* assetFileResponse( |
| 281 | status: 200, | 338 | asset, |
| 282 | headers: assetResponseHeaders( | 339 | request.method === "GET" ? request.headers.range : undefined, |
| 283 | asset.path, | 340 | request.headers["if-range"], |
| 284 | asset.download || asset.mimeType !== undefined | 341 | ).pipe( |
| 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( | ||
| 293 | Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), | 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,6 +17,9 @@ import type { |
| 17 | EnvironmentThread, | 17 | EnvironmentThread, |
| 18 | EnvironmentThreadShell, | 18 | EnvironmentThreadShell, |
| 19 | } from "@t3tools/client-runtime/state/shell"; | 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 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 24 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 22 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 25 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | @@ -59,27 +62,6 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat | ... | @@ -59,27 +62,6 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat |
| 59 | return attachment.type === "file"; | 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 | export function isVideoAttachment(attachment: ChatFileAttachment): boolean { | 65 | export function isVideoAttachment(attachment: ChatFileAttachment): boolean { |
| 84 | return videoMimeType(attachment) !== null; | 66 | return videoMimeType(attachment) !== null; |
| 85 | } | 67 | } |
docs/internals/mobile-navigation.md+57-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | # Mobile navigation headers | 1 | # Mobile navigation |
| 2 | 2 | ||
| 3 | The iOS Home and thread routes share the root native stack in | 3 | The iOS Home and thread routes share the root native stack in |
| 4 | [`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation | 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,3 +48,59 @@ iOS `MenuView` action, including nested actions. The menu library's Fabric bridg |
| 48 | rendering `MenuView` directly. Explicit colors are preserved, and destructive | 48 | rendering `MenuView` directly. Explicit colors are preserved, and destructive |
| 49 | actions default to the theme's danger foreground color. Native stack header menus | 49 | actions default to the theme's danger foreground color. Native stack header menus |
| 50 | use a separate implementation and do not need this workaround. | 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,8 +22,15 @@ T3 Code from other apps through the system share sheet. Mobile uploads happen wh |
| 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile | 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile |
| 23 | to save it or open it in another app through the system share sheet. | 23 | to 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 | 25 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 26 | built-in controls. Playback depends on the video formats and codecs that the browser supports. | 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 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name | 35 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name |
| 29 | and shows **Attach again** next to it. Attach the file again or remove it, then send. | 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,6 +199,10 @@ |
| 199 | "types": "./src/filePreview.ts", | 199 | "types": "./src/filePreview.ts", |
| 200 | "import": "./src/filePreview.ts" | 200 | "import": "./src/filePreview.ts" |
| 201 | }, | 201 | }, |
| 202 | "./video": { | ||
| 203 | "types": "./src/video.ts", | ||
| 204 | "import": "./src/video.ts" | ||
| 205 | }, | ||
| 202 | "./chatList": { | 206 | "./chatList": { |
| 203 | "types": "./src/chatList.ts", | 207 | "types": "./src/chatList.ts", |
| 204 | "import": "./src/chatList.ts" | 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,6 +331,9 @@ importers: |
| 331 | expo-device: | 331 | expo-device: |
| 332 | specifier: ~57.0.1 | 332 | specifier: ~57.0.1 |
| 333 | version: 57.0.1(expo@57.0.18) | 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 | expo-file-system: | 337 | expo-file-system: |
| 335 | specifier: ~57.0.6 | 338 | specifier: ~57.0.6 |
| 336 | 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)) | 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,6 +385,9 @@ importers: |
| 382 | expo-updates: | 385 | expo-updates: |
| 383 | specifier: ~57.0.19 | 386 | specifier: ~57.0.19 |
| 384 | 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) | 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 | expo-web-browser: | 391 | expo-web-browser: |
| 386 | specifier: ~57.0.2 | 392 | specifier: ~57.0.2 |
| 387 | 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)) | 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,6 +6568,11 @@ packages: |
| 6562 | peerDependencies: | 6568 | peerDependencies: |
| 6563 | expo: '*' | 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 | expo-eas-client@57.0.2: | 6576 | expo-eas-client@57.0.2: |
| 6566 | resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} | 6577 | resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} |
| 6567 | 6578 | ||
| ... | @@ -6731,6 +6742,13 @@ packages: | ... | @@ -6731,6 +6742,13 @@ packages: |
| 6731 | expo-dev-client: | 6742 | expo-dev-client: |
| 6732 | optional: true | 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 | expo-web-browser@57.0.2: | 6752 | expo-web-browser@57.0.2: |
| 6735 | resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} | 6753 | resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} |
| 6736 | peerDependencies: | 6754 | peerDependencies: |
| ... | @@ -16434,6 +16452,10 @@ snapshots: | ... | @@ -16434,6 +16452,10 @@ snapshots: |
| 16434 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) | 16452 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) |
| 16435 | ua-parser-js: 0.7.41 | 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 | expo-eas-client@57.0.2: {} | 16459 | expo-eas-client@57.0.2: {} |
| 16438 | 16460 | ||
| 16439 | 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)): | 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,6 +16696,12 @@ snapshots: |
| 16674 | transitivePeerDependencies: | 16696 | transitivePeerDependencies: |
| 16675 | - supports-color | 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 | 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)): | 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 | dependencies: | 16706 | dependencies: |
| 16679 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) | 16707 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) |