authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-31 12:06:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 12:06:29-07:00
log31c1c5996f88e3acf1566adc11c9b51ac7561554
treed313522332ad12f79b4016ea569883cb33392419
parentef84bc9873a6c4565fbeb64dce3f552570e95a2d
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add video playback with native iOS controls (#8919)

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
33import UIKit
44
55public final class T3NativeControlsModule: Module {
6 private let presentationSources = T3PresentationSources()
7 private var videoPresentation: T3NativeVideoPresentation?
8
69 public func definition() -> ModuleDefinition {
710 Name("T3NativeControls")
811
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
943 Function("getShowcasePairingUrl") {
1044 let arguments = ProcessInfo.processInfo.arguments
1145 guard
......@@ -101,4 +135,47 @@ public final class T3NativeControlsModule: Module {
101135 try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)
102136 }
103137 }
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 }
104181}
apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift created+75
......@@ -0,0 +1,75 @@
1import ExpoModulesCore
2import UIKit
3
4final 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
28final 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
42func 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 @@
1import AVKit
2import UIKit
3
4final 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 @@
8585 "expo-crypto": "~57.0.2",
8686 "expo-dev-client": "~57.0.16",
8787 "expo-device": "~57.0.1",
88 "expo-document-picker": "~57.0.1",
8889 "expo-file-system": "~57.0.6",
8990 "expo-font": "~57.0.2",
9091 "expo-glass-effect": "~57.0.1",
......@@ -102,6 +103,7 @@
102103 "expo-sqlite": "~57.0.2",
103104 "expo-symbols": "~57.0.2",
104105 "expo-updates": "~57.0.19",
106 "expo-video": "~57.0.3",
105107 "expo-web-browser": "~57.0.2",
106108 "expo-widgets": "~57.0.15",
107109 "punycode": "^2.3.1",
apps/mobile/src/components/ComposerAttachmentStrip.tsx+141-34
......@@ -1,8 +1,12 @@
11import { SymbolView } from "../components/AppSymbol";
2import { Image, Pressable, ScrollView, View } from "react-native";
2import { videoMimeType } from "@t3tools/shared/video";
3import { useEffect, useRef, useState } from "react";
4import { Alert, Image, Pressable, ScrollView, View } from "react-native";
35
46import { AppText as Text } from "./AppText";
5import type { DraftComposerAttachment } from "../lib/composerImages";
7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
8import { VideoAttachmentTile } from "./VideoAttachmentTile";
9import { loadLocalVideoPreview } from "../lib/localVideoPreview";
610
711export interface ComposerAttachmentStripProps {
812 /** Attachments to display. */
......@@ -11,6 +15,10 @@ export interface ComposerAttachmentStripProps {
1115 readonly onRemove: (imageId: string) => void;
1216 /** Called when the user taps on an image thumbnail to preview it. */
1317 readonly onPressImage?: (previewUri: string) => void;
18 readonly onPressVideo?: (
19 attachment: DraftComposerFileAttachment,
20 sourceIdentifier: string,
21 ) => void;
1422 /** Image thumbnail size in points. Defaults to 72. */
1523 readonly imageSize?: number;
1624 /** Border radius of each image thumbnail. Defaults to 16. */
......@@ -19,6 +27,130 @@ export interface ComposerAttachmentStripProps {
1927 readonly removeButtonPlacement?: "overlay" | "gutter";
2028}
2129
30export 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
87function 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
22154/**
23155 * Attachment thumbnails used by the thread composer and the new-task draft screen.
24156 */
......@@ -49,38 +181,13 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
49181 paddingRight: removeButtonGutter,
50182 }}
51183 >
52 {attachment.type === "image" ? (
53 <Pressable
54 onPress={
55 props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined
56 }
57 >
58 <Image
59 source={{ uri: attachment.previewUri }}
60 style={{
61 width: size,
62 height: size,
63 borderRadius: radius,
64 }}
65 className="bg-subtle"
66 resizeMode="cover"
67 />
68 </Pressable>
69 ) : (
70 <View
71 className="items-center justify-center gap-1 bg-subtle px-2"
72 style={{
73 width: size,
74 height: size,
75 borderRadius: radius,
76 }}
77 >
78 <SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" />
79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
80 {attachment.name}
81 </Text>
82 </View>
83 )}
184 <ComposerAttachmentThumbnail
185 attachment={attachment}
186 size={size}
187 borderRadius={radius}
188 onPressImage={props.onPressImage}
189 onPressVideo={props.onPressVideo}
190 />
84191 <Pressable
85192 className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"
86193 style={{
apps/mobile/src/components/NativePresentation.ios.tsx created+12
......@@ -0,0 +1,12 @@
1import { requireNativeView } from "expo";
2import type { ComponentType } from "react";
3import type { PresentationSourceProps } from "./NativePresentation";
4
5const NativeSource: ComponentType<PresentationSourceProps> = requireNativeView(
6 "T3NativeControls",
7 "PresentationSource",
8);
9
10export function PresentationSource(props: PresentationSourceProps) {
11 return <NativeSource {...props} collapsableChildren={false} />;
12}
apps/mobile/src/components/NativePresentation.tsx created+13
......@@ -0,0 +1,13 @@
1import type { ReactElement } from "react";
2import { View, type ViewProps } from "react-native";
3
4export 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. */
11export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) {
12 return <View {...props} />;
13}
apps/mobile/src/components/VideoAttachmentMenu.tsx created+53
......@@ -0,0 +1,53 @@
1import type { ReactElement } from "react";
2import { Platform, type PressableProps } from "react-native";
3
4import { ControlPillMenu } from "./ControlPill";
5import { PresentationSource } from "./NativePresentation";
6
7export 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 @@
1import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react-native";
2
3import { cn } from "../lib/cn";
4import type { DraftComposerFileAttachment } from "../lib/composerImages";
5import { SymbolView } from "./AppSymbol";
6import { AppText } from "./AppText";
7import { VideoAttachmentMenu } from "./VideoAttachmentMenu";
8import { VideoThumbnailImage } from "./VideoThumbnailImage";
9
10export 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 @@
1import { useIsFocused } from "@react-navigation/native";
2import { videoMimeType } from "@t3tools/shared/video";
3import { requireNativeModule } from "expo";
4import { useEffect, useEffectEvent, useId, useState } from "react";
5import { Alert, Keyboard } from "react-native";
6
7import { loadLocalVideoPreview } from "../lib/localVideoPreview";
8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";
10import type { VideoPreviewSource } from "./VideoPreviewModal";
11
12export type { VideoPreviewSource } from "./VideoPreviewModal";
13
14const 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
24function 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
108export 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 @@
1import { useIsFocused } from "@react-navigation/native";
2import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts";
3import { videoMimeType } from "@t3tools/shared/video";
4import { useEvent } from "expo";
5import { useVideoPlayer, VideoView } from "expo-video";
6import { useEffect, useRef, useState } from "react";
7import {
8 ActivityIndicator,
9 AppState,
10 Keyboard,
11 Modal,
12 Pressable,
13 StyleSheet,
14 View,
15} from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17
18import {
19 downloadAttachmentForPreview,
20 type AttachmentPreviewFile,
21} from "../lib/attachmentDownload";
22import type { DraftComposerFileAttachment } from "../lib/composerImages";
23import { loadLocalVideoPreview } from "../lib/localVideoPreview";
24import { useAssetUrlState } from "../state/assets";
25import { usePreparedConnection } from "../state/session";
26import { SymbolView } from "./AppSymbol";
27import { AppText } from "./AppText";
28
29export 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
38function 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
122function 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
242export 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 @@
1import { Image } from "expo-image";
2import { useIsFocused } from "@react-navigation/native";
3import type { VideoThumbnail } from "expo-video";
4import { useEffect, useState } from "react";
5import { StyleSheet } from "react-native";
6
7import type { DraftComposerFileAttachment } from "../lib/composerImages";
8import { loadLocalVideoPreview } from "../lib/localVideoPreview";
9import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";
10
11export 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 {
3535import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3636import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
3737import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
38import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
3839import { ProviderIcon } from "../../components/ProviderIcon";
3940import { SymbolView } from "../../components/AppSymbol";
4041import { AppText as Text } from "../../components/AppText";
......@@ -60,6 +61,7 @@ import {
6061 convertPastedImagesToAttachments,
6162 pickComposerFiles,
6263 pickComposerMedia,
64 type DraftComposerFileAttachment,
6365} from "../../lib/composerImages";
6466import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
6567import {
......@@ -164,6 +166,23 @@ export function NewTaskDraftScreen(props: {
164166 const promptInputRef = useRef<ComposerEditorHandle>(null);
165167 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
166168 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]);
167186 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
168187 editorRef: promptInputRef,
169188 isEditorFocused: isComposerFocused,
......@@ -1193,6 +1212,9 @@ export function NewTaskDraftScreen(props: {
11931212 ? () => undefined
11941213 : flow.removeAttachment
11951214 }
1215 onPressVideo={
1216 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
1217 }
11961218 />
11971219 </View>
11981220 ) : null}
......@@ -1296,6 +1318,7 @@ export function NewTaskDraftScreen(props: {
12961318 </ComposerDictationToolbar>
12971319 </Animated.View>
12981320 </ComposerSurface>
1321 <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} />
12991322 </View>
13001323 );
13011324
apps/mobile/src/features/threads/ThreadComposer.tsx+39-27
......@@ -19,7 +19,7 @@ import {
1919 useState,
2020 type RefObject,
2121} from "react";
22import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
2323import ImageViewing from "react-native-image-viewing";
2424import Animated, {
2525 FadeIn,
......@@ -37,9 +37,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
3737import { scopedThreadKey } from "../../lib/scopedEntities";
3838
3939import { AppText as Text } from "../../components/AppText";
40import { SymbolView } from "../../components/AppSymbol";
4140import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
42import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
41import {
42 ComposerAttachmentStrip,
43 ComposerAttachmentThumbnail,
44} from "../../components/ComposerAttachmentStrip";
45import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
4346import { GlassSurface } from "../../components/GlassSurface";
4447import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
4548import {
......@@ -48,7 +51,10 @@ import {
4851 ComposerToolbarRow,
4952} from "../../components/ComposerToolbar";
5053import { ProviderIcon } from "../../components/ProviderIcon";
51import type { DraftComposerAttachment } from "../../lib/composerImages";
54import type {
55 DraftComposerAttachment,
56 DraftComposerFileAttachment,
57} from "../../lib/composerImages";
5258import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5359import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5460import type { RemoteClientConnectionState } from "../../lib/connection";
......@@ -306,6 +312,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
306312 const { onExpandedChange } = props;
307313
308314 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
309316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
310317 const showStopAction =
311318 !hasContent &&
......@@ -368,6 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
368375 const onPressImage = useCallback(
369376 (uri: string) => {
370377 wasExpandedBeforePreviewRef.current = isFocused;
378 setPreviewVideo(null);
371379 setPreviewImageUri(uri);
372380 },
373381 [isFocused],
......@@ -375,10 +383,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
375383
376384 const closePreview = useCallback(() => {
377385 setPreviewImageUri(null);
386 setPreviewVideo(null);
378387 if (wasExpandedBeforePreviewRef.current) {
379 setTimeout(() => inputRef.current?.focus(), 100);
388 setTimeout(() => {
389 if (navigation.isFocused()) inputRef.current?.focus();
390 }, 100);
380391 }
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 );
382402
383403 const onEditorFocusChange = props.onEditorFocusChange;
384404 const handleFocus = useCallback(() => {
......@@ -600,6 +620,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
600620 attachments={props.draftAttachments}
601621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
602622 onPressImage={voiceInput.isBusy ? undefined : onPressImage}
623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
603624 />
604625 </Animated.View>
605626 ) : null}
......@@ -645,27 +666,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
645666 </Animated.View>
646667 {!isExpanded && props.draftAttachments.length > 0 ? (
647668 <View className="flex-row gap-1 pl-1">
648 {props.draftAttachments.slice(0, 3).map((attachment) =>
649 attachment.type === "image" ? (
650 <Pressable
651 key={attachment.id}
652 onPress={() => onPressImage(attachment.previewUri)}
653 >
654 <Image
655 source={{ uri: attachment.previewUri }}
656 className="size-[30px] rounded-lg bg-subtle"
657 resizeMode="cover"
658 />
659 </Pressable>
660 ) : (
661 <View
662 key={attachment.id}
663 className="size-[30px] items-center justify-center rounded-lg bg-subtle"
664 >
665 <SymbolView name="doc.text" size={15} tintColor="#a3a3a3" type="monochrome" />
666 </View>
667 ),
668 )}
669 {props.draftAttachments.slice(0, 3).map((attachment) => (
670 <ComposerAttachmentThumbnail
671 key={attachment.id}
672 attachment={attachment}
673 size={30}
674 borderRadius={8}
675 compact
676 onPressImage={onPressImage}
677 onPressVideo={onPressVideo}
678 />
679 ))}
669680 {props.draftAttachments.length > 3 ? (
670681 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
671682 <Text className="text-foreground-muted text-2xs font-t3-bold">
......@@ -807,6 +818,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
807818 ) : null}
808819 </Animated.View>
809820
821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
810822 <ImageViewing
811823 images={previewImageUri ? [{ uri: previewImageUri }] : []}
812824 imageIndex={0}
apps/mobile/src/features/threads/ThreadFeed.tsx+104-42
......@@ -23,6 +23,7 @@ import {
2323 splitCodexArtifactTemplateMarkdown,
2424} from "@t3tools/client-runtime/codex-markdown-directives";
2525import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
26import { videoMimeType } from "@t3tools/shared/video";
2627import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
2728import { HeaderHeightContext } from "@react-navigation/elements";
2829import { useFocusEffect, useNavigation } from "@react-navigation/native";
......@@ -88,6 +89,8 @@ import {
8889} from "../../native/SelectableMarkdownText";
8990
9091import { AppText as Text } from "../../components/AppText";
92import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
93import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
9194import { CopyTextButton } from "../../components/CopyTextButton";
9295import {
9396 parseReviewCommentMessageSegments,
......@@ -246,12 +249,25 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt
246249function MessageAttachmentFile(props: {
247250 readonly environmentId: EnvironmentId;
248251 readonly attachment: ChatFileAttachment;
252 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
249253}) {
250254 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
251255 reportFailure: false,
252256 });
253257 const preparedConnection = usePreparedConnection(props.environmentId);
254258 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 );
255271 const httpBaseUrl = Option.isSome(preparedConnection)
256272 ? preparedConnection.value.httpBaseUrl
257273 : null;
......@@ -268,6 +284,69 @@ function MessageAttachmentFile(props: {
268284 }, [props.environmentId, attachment.id, httpBaseUrl]),
269285 );
270286
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
271350 return (
272351 <Pressable
273352 accessibilityRole="button"
......@@ -275,48 +354,7 @@ function MessageAttachmentFile(props: {
275354 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
276355 disabled={opening || httpBaseUrl === null}
277356 className="flex-row items-center gap-2 py-1"
278 onPress={() => {
279 if (httpBaseUrl === null || openingRef.current) return;
280 const controller = new AbortController();
281 openingRef.current = controller;
282 setOpening(true);
283 void (async () => {
284 try {
285 const result = await createAssetUrl({
286 environmentId: props.environmentId,
287 input: {
288 resource: {
289 _tag: "attachment",
290 attachmentId: attachment.id,
291 fileName: attachment.name,
292 mimeType: attachment.mimeType,
293 },
294 },
295 });
296 if (controller.signal.aborted) return;
297 if (result._tag === "Failure") {
298 throw squashAtomCommandFailure(result);
299 }
300 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
301 if (url === null) {
302 throw new Error("The attachment could not be opened.");
303 }
304 await downloadAndShareAttachment({ url, attachment, signal: controller.signal });
305 } catch (error) {
306 if (!controller.signal.aborted) {
307 Alert.alert(
308 "Could not open attachment",
309 error instanceof Error ? error.message : "The attachment is unavailable.",
310 );
311 }
312 } finally {
313 if (openingRef.current === controller) {
314 openingRef.current = null;
315 setOpening(false);
316 }
317 }
318 })();
319 }}
357 onPress={() => shareFile()}
320358 >
321359 {opening ? (
322360 <ActivityIndicator size="small" />
......@@ -1227,6 +1265,7 @@ function renderFeedEntry(
12271265 readonly onToggleWorkRow: (rowId: string) => void;
12281266 readonly onToggleTurnFold: (turnId: TurnId) => void;
12291267 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
1268 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
12301269 readonly onMarkdownLinkPress: (href: string) => void;
12311270 readonly renderMarkdownImage: MarkdownImageRenderer;
12321271 readonly iconSubtleColor: string | import("react-native").ColorValue;
......@@ -1344,6 +1383,7 @@ function renderFeedEntry(
13441383 key={attachment.id}
13451384 environmentId={props.environmentId}
13461385 attachment={attachment}
1386 onPressVideo={props.onPressVideo}
13471387 />
13481388 ) : (
13491389 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
......@@ -1404,6 +1444,7 @@ function renderFeedEntry(
14041444 key={attachment.id}
14051445 environmentId={props.environmentId}
14061446 attachment={attachment}
1447 onPressVideo={props.onPressVideo}
14071448 />
14081449 ) : (
14091450 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
......@@ -1769,6 +1810,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17691810 uri: string;
17701811 headers?: Record<string, string>;
17711812 } | null>(null);
1813 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1814 useEffect(() => {
1815 setExpandedVideo(null);
1816 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
17721817 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
17731818 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
17741819 viewportWidth,
......@@ -2243,6 +2288,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22432288 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
22442289 setExpandedImage({ uri, headers });
22452290 }, []);
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 );
22462305
22472306 // Rows whose height is known before they ever render. Without this, every
22482307 // row above the viewport is assumed to be estimatedItemSize tall, and
......@@ -2292,6 +2351,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22922351 onToggleWorkRow,
22932352 onToggleTurnFold,
22942353 onPressImage,
2354 onPressVideo,
22952355 onMarkdownLinkPress,
22962356 renderMarkdownImage,
22972357 iconSubtleColor,
......@@ -2320,6 +2380,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23202380 onCopyWorkRow,
23212381 onMarkdownLinkPress,
23222382 onPressImage,
2383 onPressVideo,
23232384 onToggleTurnFold,
23242385 onToggleWorkGroup,
23252386 onToggleWorkRow,
......@@ -2503,6 +2564,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25032564 ) : null}
25042565 </View>
25052566
2567 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
25062568 <ImageViewing
25072569 images={
25082570 expandedImage
apps/mobile/src/lib/attachmentDownload.test.ts+133-3
......@@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => ({
44 directories: new Set<string>(),
55 deleted: vi.fn(),
66 download: vi.fn(),
7 copy: vi.fn(),
78 share: vi.fn(),
9 shareFromSource: vi.fn(),
810 available: vi.fn(),
911 uuid: vi.fn(),
1012}));
......@@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => {
4648 static downloadFileAsync = mocks.download;
4749 readonly uri: string;
4850
49 constructor(directory: Directory, name: string) {
50 this.uri = `${directory.uri}/${encodeURIComponent(name)}`;
51 constructor(source: Directory | string, name?: string) {
52 this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`;
53 }
54
55 async copy(destination: File): Promise<void> {
56 await mocks.copy(this.uri, destination.uri);
5157 }
5258 }
5359
......@@ -60,8 +66,13 @@ vi.mock("expo-sharing", () => ({
6066}));
6167
6268vi.mock("./uuid", () => ({ uuidv4: mocks.uuid }));
69vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource }));
6370
64import { downloadAndShareAttachment } from "./attachmentDownload";
71import {
72 downloadAndShareAttachment,
73 downloadAttachmentForPreview,
74 shareLocalAttachment,
75} from "./attachmentDownload";
6576import { isForegroundHandoffActive } from "./foreground-handoff";
6677
6778const NOW = 1_787_990_400_000;
......@@ -76,11 +87,15 @@ beforeEach(() => {
7687 mocks.directories.clear();
7788 mocks.deleted.mockReset();
7889 mocks.download.mockReset();
90 mocks.copy.mockReset();
7991 mocks.share.mockReset();
92 mocks.shareFromSource.mockReset();
8093 mocks.available.mockReset();
8194 mocks.uuid.mockReset();
8295 mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file);
96 mocks.copy.mockResolvedValue(undefined);
8397 mocks.share.mockResolvedValue(undefined);
98 mocks.shareFromSource.mockResolvedValue(undefined);
8499 mocks.available.mockResolvedValue(true);
85100 let sequence = 0;
86101 mocks.uuid.mockImplementation(
......@@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => {
268283 await first;
269284 });
270285});
286
287describe("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 @@
11import type { ChatFileAttachment } from "@t3tools/contracts";
22import type { Directory } from "expo-file-system";
3import type { SharingOptions } from "expo-sharing";
34
45import { beginForegroundHandoff } from "./foreground-handoff";
56import { uuidv4 } from "./uuid";
......@@ -52,23 +53,27 @@ function removeDownloadDirectory(directory: Directory): void {
5253 }
5354}
5455
55/** Downloads original bytes for the native save/share sheet, including inline video responses. */
56export async function downloadAndShareAttachment(input: {
57 readonly url: string;
58 readonly attachment: Pick<ChatFileAttachment, "name" | "mimeType">;
59 readonly signal: AbortSignal;
60}): Promise<void> {
61 const [{ Directory, File, Paths }, Sharing] = await Promise.all([
62 import("expo-file-system"),
63 import("expo-sharing"),
64 ]);
65 if (input.signal.aborted) return;
56type AttachmentFileMetadata = Pick<ChatFileAttachment, "name" | "mimeType">;
57
58export interface AttachmentPreviewFile {
59 readonly uri: string;
60 readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise<void>;
61 readonly dispose: () => void;
62}
63
64async function availableSharing(signal: AbortSignal) {
65 if (signal.aborted) return null;
66 const Sharing = await import("expo-sharing");
6667 const canShare = await Sharing.isAvailableAsync();
67 if (input.signal.aborted) return;
68 if (signal.aborted) return null;
6869 if (!canShare) {
6970 throw new Error("Saving and sharing files is unavailable on this device.");
7071 }
72 return Sharing;
73}
7174
75async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) {
76 const { Directory, File, Paths } = await import("expo-file-system");
7277 const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY);
7378 cache.create({ idempotent: true, intermediates: true });
7479 const now = Date.now();
......@@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: {
8994 }
9095
9196 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 }
92105 activeDirectories.add(directory.uri);
106 let disposed = false;
93107 let shared = false;
94 let openingShareSheet = false;
95 try {
96 directory.create();
97 const destination = new File(directory, downloadFileName(input.attachment.name));
98 const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal });
99 if (input.signal.aborted) return;
108 let sharing = false;
109 const release = () => {
110 if (!disposed || sharing) return;
111 activeDirectories.delete(directory.uri);
112 // A receiver can still be reading after Android's chooser returns.
113 if (!shared) removeDownloadDirectory(directory);
114 };
115 const preview: AttachmentPreviewFile = {
116 uri: file.uri,
117 dispose: () => {
118 disposed = true;
119 release();
120 },
121 share: async (signal, sourceIdentifier) => {
122 if (disposed || sharing || signal.aborted) return;
123 sharing = true;
124 try {
125 const Sharing = await availableSharing(signal);
126 if (Sharing === null || disposed) return;
127 const endHandoff = beginForegroundHandoff();
128 try {
129 const options: SharingOptions = {
130 mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
131 dialogTitle: attachment.name,
132 };
133 if (sourceIdentifier) {
134 const { shareFileFromSource } = await import("./shareFileFromSource");
135 if (signal.aborted || disposed) return;
136 await shareFileFromSource(file.uri, options, sourceIdentifier);
137 } else {
138 await Sharing.shareAsync(file.uri, options);
139 }
140 shared = true;
141 } catch (cause) {
142 if (!signal.aborted) {
143 throw new Error("Could not open the share sheet. Try again.", { cause });
144 }
145 } finally {
146 endHandoff();
147 }
148 } finally {
149 sharing = false;
150 release();
151 }
152 },
153 };
154 return { file, preview };
155}
100156
101 openingShareSheet = true;
102 const endHandoff = beginForegroundHandoff();
103 try {
104 await Sharing.shareAsync(file.uri, {
105 mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
106 dialogTitle: input.attachment.name,
107 });
108 shared = true;
109 } finally {
110 endHandoff();
157/** The caller owns this cached file until disposal, unless it has been shared with another app. */
158export async function downloadAttachmentForPreview(input: {
159 readonly url: string;
160 readonly attachment: AttachmentFileMetadata;
161 readonly signal: AbortSignal;
162}): Promise<AttachmentPreviewFile | null> {
163 if (input.signal.aborted) return null;
164 const { File } = await import("expo-file-system");
165 const cached = await createCachedAttachmentFile(input.attachment);
166 try {
167 if (input.signal.aborted) {
168 cached.preview.dispose();
169 return null;
170 }
171 await File.downloadFileAsync(input.url, cached.file, { signal: input.signal });
172 if (input.signal.aborted) {
173 cached.preview.dispose();
174 return null;
111175 }
176 return cached.preview;
112177 } catch (cause) {
113 if (input.signal.aborted) return;
114 throw new Error(
115 openingShareSheet
116 ? "Could not open the share sheet. Try again."
117 : "Could not download the attachment. Check the connection and try again.",
118 { cause },
119 );
178 // Android may leave a partial file after a failed or interrupted request.
179 cached.preview.dispose();
180 if (input.signal.aborted) return null;
181 throw new Error("Could not download the attachment. Check the connection and try again.", {
182 cause,
183 });
184 }
185}
186
187/** Downloads original bytes for the native save/share sheet, including inline video responses. */
188export 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);
120199 } finally {
121 activeDirectories.delete(directory.uri);
122 // A receiver can still be reading after Android's chooser returns.
123 // Successful exports expire on a later open; partial downloads do not.
124 if (!shared) {
125 removeDownloadDirectory(directory);
200 file.dispose();
201 }
202}
203
204/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */
205export 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 });
126221 }
222 if (!input.signal.aborted) {
223 await cached.preview.share(input.signal, input.sourceIdentifier);
224 }
225 } finally {
226 cached.preview.dispose();
127227 }
128228}
apps/mobile/src/lib/composerAttachmentFiles.ts+25
......@@ -6,6 +6,7 @@ const IOS_DOCUMENTS_PATH = new RegExp(
66 `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`,
77 "i",
88);
9const retainedFiles = new Map<string, number>();
910
1011function fileUriPath(uri: string): string | null {
1112 try {
......@@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string {
5253 return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`;
5354}
5455
56/** Holds a local copy until its last player or share-copy operation releases it. */
57export 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
76export function isComposerAttachmentFileRetained(uri: string): boolean {
77 return retainedFiles.has(composerAttachmentFileReferenceKey(uri));
78}
79
5580/**
5681 * Resolves only our saved attachment copies. iOS preserves Documents on updates
5782 * 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", () => {
2323 }
2424
2525 class File {
26 static pickFileAsync = mocks.pickFile;
27
2826 readonly uri: string;
2927
3028 constructor(source: string | Directory, name?: string) {
......@@ -75,6 +73,7 @@ vi.mock("expo-file-system", () => {
7573});
7674
7775vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));
76vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile }));
7877vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));
7978
8079import {
......@@ -85,6 +84,7 @@ import {
8584 removePersistedComposerAttachmentFile,
8685} from "./composerImages";
8786import { isForegroundHandoffActive } from "./foreground-handoff";
87import { retainComposerAttachmentFile } from "./composerAttachmentFiles";
8888
8989describe("composer file attachments", () => {
9090 beforeEach(() => {
......@@ -274,11 +274,11 @@ describe("composer file attachments", () => {
274274 it("copies picked files into app-owned storage without loading their contents", async () => {
275275 mocks.pickFile.mockResolvedValue({
276276 canceled: false,
277 result: [
277 assets: [
278278 {
279279 uri: "file:///downloads/report.pdf",
280280 name: "report.pdf",
281 type: "application/pdf",
281 mimeType: "application/pdf",
282282 size: 42,
283283 },
284284 ],
......@@ -303,14 +303,120 @@ describe("composer file attachments", () => {
303303 );
304304 });
305305
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
306412 it("falls back to a usable name when the picker reports a blank one", async () => {
307413 mocks.pickFile.mockResolvedValue({
308414 canceled: false,
309 result: [
415 assets: [
310416 {
311417 uri: "file:///downloads/unnamed",
312418 name: " ",
313 type: "application/pdf",
419 mimeType: "application/pdf",
314420 size: 42,
315421 },
316422 ],
......@@ -325,11 +431,11 @@ describe("composer file attachments", () => {
325431 it("rejects files that exceed the environment's advertised upload limit", async () => {
326432 mocks.pickFile.mockResolvedValue({
327433 canceled: false,
328 result: [
434 assets: [
329435 {
330436 uri: "file:///downloads/archive.zip",
331437 name: "archive.zip",
332 type: "application/zip",
438 mimeType: "application/zip",
333439 size: 2 * 1024 * 1024,
334440 },
335441 ],
......@@ -345,11 +451,11 @@ describe("composer file attachments", () => {
345451 it("never accepts files above the 50 MB contract limit", async () => {
346452 mocks.pickFile.mockResolvedValue({
347453 canceled: false,
348 result: [
454 assets: [
349455 {
350456 uri: "file:///downloads/archive.zip",
351457 name: "archive.zip",
352 type: "application/zip",
458 mimeType: "application/zip",
353459 size: 51 * 1024 * 1024,
354460 },
355461 ],
......@@ -366,11 +472,11 @@ describe("composer file attachments", () => {
366472 it("rejects a file that grew after the picker reported its size", async () => {
367473 mocks.pickFile.mockResolvedValue({
368474 canceled: false,
369 result: [
475 assets: [
370476 {
371477 uri: "file:///downloads/archive.zip",
372478 name: "archive.zip",
373 type: "application/zip",
479 mimeType: "application/zip",
374480 size: 42,
375481 },
376482 ],
......@@ -434,11 +540,11 @@ describe("composer file attachments", () => {
434540 mocks.size.mockReturnValue(0);
435541 mocks.pickFile.mockResolvedValue({
436542 canceled: false,
437 result: [
543 assets: [
438544 {
439545 uri: "file:///downloads/empty.txt",
440546 name: "empty.txt",
441 type: "text/plain",
547 mimeType: "text/plain",
442548 size: 0,
443549 },
444550 ],
......@@ -450,7 +556,7 @@ describe("composer file attachments", () => {
450556 });
451557 });
452558
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) => {
454560 const reader = {
455561 readBytes: vi
456562 .fn()
......@@ -463,12 +569,12 @@ describe("composer file attachments", () => {
463569 mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer));
464570 mocks.pickFile.mockResolvedValue({
465571 canceled: false,
466 result: [
572 assets: [
467573 {
468574 uri: "content://shared/report",
469575 name: "report.pdf",
470 type: "application/pdf",
471 size: 0,
576 mimeType: "application/pdf",
577 size,
472578 },
473579 ],
474580 });
......@@ -491,17 +597,17 @@ describe("composer file attachments", () => {
491597 it("uses the remaining slot for the first valid file after an oversized selection", async () => {
492598 mocks.pickFile.mockResolvedValue({
493599 canceled: false,
494 result: [
600 assets: [
495601 {
496602 uri: "file:///downloads/huge.zip",
497603 name: "huge.zip",
498 type: "application/zip",
604 mimeType: "application/zip",
499605 size: 2 * 1024 * 1024,
500606 },
501607 {
502608 uri: "file:///downloads/report.pdf",
503609 name: "report.pdf",
504 type: "application/pdf",
610 mimeType: "application/pdf",
505611 size: 42,
506612 },
507613 ],
......@@ -557,6 +663,26 @@ describe("composer file attachments", () => {
557663 ]);
558664 });
559665
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
560686 it("copies an open-in-place source from its actual container without rebasing it", async () => {
561687 const sourceUri =
562688 "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 {
1010 type EnvironmentId,
1111 type UploadChatImageAttachment,
1212} from "@t3tools/contracts";
13import type { PickMultipleFilesResult } from "expo-file-system";
13import type { DocumentPickerResult } from "expo-document-picker";
1414import { estimateBase64ByteSize } from "./base64";
1515import {
1616 COMPOSER_ATTACHMENT_DIRECTORY,
17 isComposerAttachmentFileRetained,
1718 resolveOwnedComposerAttachmentFileUri,
1819} from "./composerAttachmentFiles";
1920import { beginForegroundHandoff } from "./foreground-handoff";
......@@ -145,7 +146,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis
145146 try {
146147 const { File, Paths } = await import("expo-file-system");
147148 const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri);
148 if (ownedUri === null) {
149 if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) {
149150 return;
150151 }
151152 const file = new File(ownedUri);
......@@ -206,11 +207,18 @@ export async function pickComposerFiles(input: {
206207 };
207208 }
208209
209 const { File } = await import("expo-file-system");
210 const { getDocumentAsync } = await import("expo-document-picker");
210211 const endHandoff = beginForegroundHandoff();
211 let result: PickMultipleFilesResult;
212 let result: DocumentPickerResult;
212213 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 };
214222 } finally {
215223 endHandoff();
216224 }
......@@ -224,7 +232,7 @@ export async function pickComposerFiles(input: {
224232 const attachments: DraftComposerFileAttachment[] = [];
225233 let error: string | null = null;
226234 let exceededAttachmentLimit = false;
227 for (const file of result.result) {
235 for (const file of result.assets) {
228236 if (attachments.length >= remainingSlots) {
229237 exceededAttachmentLimit = true;
230238 break;
......@@ -238,7 +246,7 @@ export async function pickComposerFiles(input: {
238246 await createComposerFileAttachment({
239247 uri: file.uri,
240248 name,
241 mimeType: file.type || "application/octet-stream",
249 mimeType: file.mimeType || "application/octet-stream",
242250 sizeBytes: file.size ?? null,
243251 maxBytes,
244252 }),
apps/mobile/src/lib/localVideoPreview.test.ts created+114
......@@ -0,0 +1,114 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 retain: vi.fn(),
5 share: vi.fn(),
6 exists: vi.fn(),
7}));
8
9vi.mock("../state/use-composer-drafts", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
13vi.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
27import { loadLocalVideoPreview } from "./localVideoPreview";
28
29const 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
39beforeEach(() => {
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
48describe("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 @@
1import { videoMimeType } from "@t3tools/shared/video";
2
3import type { DraftComposerFileAttachment } from "./composerImages";
4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
7
8/** Retains the draft original for playback and gives each outgoing share its own lease. */
9export 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 @@
1import { requireNativeModule } from "expo";
2import type { SharingOptions } from "expo-sharing";
3
4const NativeControls = requireNativeModule<{
5 shareFileFromSource(uri: string, title: string, sourceIdentifier: string): Promise<void>;
6}>("T3NativeControls");
7
8export 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 @@
1import { shareAsync, type SharingOptions } from "expo-sharing";
2
3export 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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() }));
4vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer }));
5
6let thumbnails: typeof import("./videoThumbnails");
7const frame = { width: 480, height: 270 };
8const player = () => ({
9 replaceAsync: vi.fn(async (): Promise<void> => {}),
10 generateThumbnailsAsync: vi.fn(async () => [frame]),
11 release: vi.fn(),
12});
13const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() });
14
15beforeEach(async () => {
16 vi.resetModules();
17 mocks.createPlayer.mockReset().mockImplementation(player);
18 thumbnails = await import("./videoThumbnails");
19});
20
21afterEach(() => vi.useRealTimers());
22
23describe("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 @@
1import type { VideoThumbnail } from "expo-video";
2
3import type { AttachmentPreviewFile } from "./attachmentDownload";
4
5const thumbnails = new Map<string, VideoThumbnail>();
6const MAX_CACHED_THUMBNAILS = 32;
7let pending: Promise<unknown> = Promise.resolve();
8
9export function cachedVideoThumbnail(key: string): VideoThumbnail | null {
10 return thumbnails.get(key) ?? null;
11}
12
13async 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. */
48export 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 {
134134 releaseUnusedComposerAttachmentFiles,
135135 removeComposerDraftsForEnvironment,
136136 resetComposerDraftsLoadState,
137 retainComposerAttachmentFileForPreview,
137138 restoreComposerDraftSnapshotState,
138139 setComposerDraftText,
139140 setStickyComposerModelSelection,
......@@ -319,6 +320,82 @@ describe("mobile composer drafts", () => {
319320 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
320321 });
321322
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
322399 it("removes an unreferenced local file and its pending upload", async () => {
323400 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
324401 onTestFinished(() => outboxLoad.mockRestore());
apps/mobile/src/state/use-composer-drafts.ts+18-2
......@@ -15,8 +15,12 @@ import { Atom } from "effect/unstable/reactivity";
1515
1616import { writeFileAtomically } from "../lib/atomic-file";
1717import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
18import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles";
19import type { DraftComposerAttachment } from "../lib/composerImages";
18import {
19 composerAttachmentFileReferenceKey,
20 isComposerAttachmentFileRetained,
21 retainComposerAttachmentFile,
22} from "../lib/composerAttachmentFiles";
23import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
2024import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
2125import { appAtomRegistry } from "./atom-registry";
2226import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
......@@ -287,6 +291,9 @@ export async function flushComposerDrafts(): Promise<void> {
287291}
288292
289293function isComposerAttachmentFileReferenced(fileUri: string): boolean {
294 if (isComposerAttachmentFileRetained(fileUri)) {
295 return true;
296 }
290297 const referenceKey = composerAttachmentFileReferenceKey(fileUri);
291298 const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom));
292299 const queuedMessages = Object.values(
......@@ -436,6 +443,15 @@ export function scheduleUnusedComposerAttachmentCleanup(
436443 });
437444}
438445
446/** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */
447export function retainComposerAttachmentFileForPreview(
448 attachment: DraftComposerFileAttachment,
449): () => void {
450 return retainComposerAttachmentFile(attachment.fileUri, () => {
451 scheduleUnusedComposerAttachmentCleanup([attachment]);
452 });
453}
454
439455function schedulePersistComposerState(): void {
440456 if (persistTimer !== null) {
441457 clearTimeout(persistTimer);
apps/server/src/http.test.ts+89
......@@ -1,13 +1,102 @@
11import { expect, it } from "@effect/vitest";
22import { describe } from "vite-plus/test";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import { HttpServerResponse } from "effect/unstable/http";
310
411import {
512 assetResponseHeaders,
13 assetFileResponse,
614 downloadContentDisposition,
715 isLoopbackHostname,
816 resolveDevRedirectUrl,
917} from "./http.ts";
1018
19const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
20
21describe("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
11100describe("http dev routing", () => {
12101 it("treats localhost and loopback addresses as local", () => {
13102 expect(isLoopbackHostname("127.0.0.1")).toBe(true);
apps/server/src/http.ts+62-13
......@@ -112,6 +112,63 @@ export function assetResponseHeaders(
112112 };
113113}
114114
115/** A single byte range for native video readers; unsupported range syntax uses the full file. */
116function 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
135export 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
115172export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
116173 global: true,
117174});
......@@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add(
277334 if (!asset) {
278335 return HttpServerResponse.text("Not Found", { status: 404 });
279336 }
280 return yield* HttpServerResponse.file(asset.path, {
281 status: 200,
282 headers: assetResponseHeaders(
283 asset.path,
284 asset.download || asset.mimeType !== undefined
285 ? {
286 ...(asset.download ? { download: true } : {}),
287 ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
288 ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
289 }
290 : undefined,
291 ),
292 }).pipe(
337 return yield* assetFileResponse(
338 asset,
339 request.method === "GET" ? request.headers.range : undefined,
340 request.headers["if-range"],
341 ).pipe(
293342 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
294343 );
295344 }),
apps/web/src/types.ts+3-21
......@@ -17,6 +17,9 @@ import type {
1717 EnvironmentThread,
1818 EnvironmentThreadShell,
1919} from "@t3tools/client-runtime/state/shell";
20import { videoMimeType } from "@t3tools/shared/video";
21
22export { videoMimeType } from "@t3tools/shared/video";
2023
2124export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
2225export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
......@@ -59,27 +62,6 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat
5962 return attachment.type === "file";
6063}
6164
62const 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
72export 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
8365export function isVideoAttachment(attachment: ChatFileAttachment): boolean {
8466 return videoMimeType(attachment) !== null;
8567}
docs/internals/mobile-navigation.md+57-1
......@@ -1,4 +1,4 @@
1# Mobile navigation headers
1# Mobile navigation
22
33The iOS Home and thread routes share the root native stack in
44[`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
4848rendering `MenuView` directly. Explicit colors are preserved, and destructive
4949actions default to the theme's danger foreground color. Native stack header menus
5050use 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
55full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child
56and pass the stable identifier to the presentation. The registry keeps weak
57references to source views; recycled or compact composer thumbnails can register
58the same identifier. Identifiers must distinguish simultaneously visible attachments.
59This source registration does not own playback and can be reused by a future image
60viewer. Android uses a regular view.
61
62On iOS, video previews mount `AVPlayerViewController` temporarily inside the
63registered source and enter full screen through AVKit. AVKit
64owns that zoom, its playback controls, Close button, and interactive dismissal.
65Do not replace AVKit's transition with `preferredTransition`: in the iOS 27
66simulator, that leaves native Close unable to exit full screen. When the source is unavailable,
67the player uses a standard modal presentation. Programmatic entry uses the same
68guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
69if that selector is unavailable, the player also falls back to a standard modal.
70Images retain their existing viewer.
71
72Received videos open directly from their signed asset URL. AVKit handles buffering;
73the client does not download the entire file or show a separate opening overlay before
74presentation. The URL is captured once per preview so credential refresh does not
75restart playback. Saving or sharing still downloads the original file.
76
77The native presentation promise completes after dismissal. Local draft previews
78hold their file lease until that promise settles. The iOS preview component requests
79native dismissal when its source screen unmounts. Playback pauses in the background.
80AVPlayer activates audio as playback starts. The presenter pauses and releases
81its own player on close, then restores the previous audio-session configuration
82if no other component changed it during playback. It does not deactivate the
83shared session, which may still serve another player or recorder. Android retains
84its React Native modal and Expo Video player.
85
86`shareFileFromSource` uses the same source registration to anchor UIKit's activity
87controller. Its promise completes when the native share flow finishes, keeping
88the existing attachment lease and foreground handoff active for that duration.
89Android uses Expo Sharing. On iOS, received and draft video attachments expose
90Save or share through `VideoAttachmentMenu`. The attachment supplies the source
91identifier, and the native share presentation inherits its appearance. AVKit's
92iOS playback controls do not expose a public custom-share-action API.
93
94Video attachment thumbnails use Expo Video's native frame extraction and Expo Image.
95Received attachments use their signed asset URL; drafts retain and resolve their local file
96until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels
97pending work; a 15-second limit prevents an unreachable source from holding up the queue.
98The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by
99environment and attachment identity rather than expiring URLs. Images still displayed keep
100their own references when evicted from that cache.
101
102The asset HTTP route supports single byte ranges for videos so iOS can read metadata and
103frames without first downloading the whole file. Normal downloads keep their full response;
104unsupported ranges and conditional `If-Range` requests also fall back to the full file.
105An older environment without range support may still show the play-card fallback. Thumbnail
106failure 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
2222sends, so queued messages keep their files until they deliver. Select a received file on mobile
2323to save it or open it in another app through the system share sheet.
2424
25On web and desktop, select a video attachment before or after sending to play it with the browser's
26built-in controls. Playback depends on the video formats and codecs that the browser supports.
25Select a video attachment before or after sending to play it. Web and desktop use the browser's
26built-in controls. On mobile, videos open in a full-screen player with native playback controls.
27Supported videos show a thumbnail in the conversation and composer.
28On iOS, received videos stream from their environment as they play. Supported formats and codecs
29depend on the browser or device; you can save an unsupported video to open it in another app.
30
31On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the
32conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open
33the system share options. On Android, use **Save or share video** inside the preview.
2734
2835On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name
2936and shows **Attach again** next to it. Attach the file again or remove it, then send.
packages/shared/package.json+4
......@@ -199,6 +199,10 @@
199199 "types": "./src/filePreview.ts",
200200 "import": "./src/filePreview.ts"
201201 },
202 "./video": {
203 "types": "./src/video.ts",
204 "import": "./src/video.ts"
205 },
202206 "./chatList": {
203207 "types": "./src/chatList.ts",
204208 "import": "./src/chatList.ts"
packages/shared/src/video.test.ts created+24
......@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { videoMimeType } from "./video.ts";
4
5describe("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 @@
1const 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. */
12export 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:
331331 expo-device:
332332 specifier: ~57.0.1
333333 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)
334337 expo-file-system:
335338 specifier: ~57.0.6
336339 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:
382385 expo-updates:
383386 specifier: ~57.0.19
384387 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)
385391 expo-web-browser:
386392 specifier: ~57.0.2
387393 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:
65626568 peerDependencies:
65636569 expo: '*'
65646570
6571 expo-document-picker@57.0.1:
6572 resolution: {integrity: sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==}
6573 peerDependencies:
6574 expo: '*'
6575
65656576 expo-eas-client@57.0.2:
65666577 resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==}
65676578
......@@ -6731,6 +6742,13 @@ packages:
67316742 expo-dev-client:
67326743 optional: true
67336744
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
67346752 expo-web-browser@57.0.2:
67356753 resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==}
67366754 peerDependencies:
......@@ -16434,6 +16452,10 @@ snapshots:
1643416452 expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc)
1643516453 ua-parser-js: 0.7.41
1643616454
16455 expo-document-picker@57.0.1(expo@57.0.18):
16456 dependencies:
16457 expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc)
16458
1643716459 expo-eas-client@57.0.2: {}
1643816460
1643916461 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:
1667416696 transitivePeerDependencies:
1667516697 - supports-color
1667616698
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
1667716705 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)):
1667816706 dependencies:
1667916707 expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc)