authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 16:05:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 16:05:15-07:00
logbeae2147a9487ec47ac992319f2216914b4cb62d
treee134b57f80ff35161c357f8b7c2b163c47738839
parent60cef47ec983637ddc68faed7b1488b6f3c3a175
signature Signed by PGP key B5690EEEBB952194

fix(media): preview host files and stream videos across clients (#9023)


82 files changed, 4144 insertions(+), 682 deletions(-)

apps/desktop/src/electron/ElectronProtocol.test.ts+1-1
......@@ -225,7 +225,7 @@ describe("ElectronProtocol", () => {
225225 "http:",
226226 "https:",
227227 ]);
228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]);
229229 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
230230 });
231231});
apps/desktop/src/electron/ElectronProtocol.ts+3-1
......@@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
8787 `script-src ${scriptSources.join(" ")}`,
8888 `connect-src ${connectSources.join(" ")}`,
8989 `img-src 'self' ${input.scheme}: blob: data: http: https:`,
90 `media-src 'self' ${input.scheme}: blob:`,
90 `media-src 'self' ${input.scheme}: blob: http: https:`,
9191 "style-src 'self' 'unsafe-inline'",
9292 `font-src 'self' ${input.scheme}: data:`,
9393 "worker-src 'self' blob:",
......@@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
118118 secure: true,
119119 supportFetchAPI: true,
120120 corsEnabled: true,
121 stream: true,
121122 },
122123 },
123124 {
......@@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
127128 secure: true,
128129 supportFetchAPI: true,
129130 corsEnabled: true,
131 stream: true,
130132 },
131133 },
132134 ]);
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png differ
apps/mobile/modules/t3-markdown-text/package.json+2
......@@ -26,6 +26,8 @@
2626 "./types": "./src/SelectableMarkdownText.types.ts"
2727 },
2828 "peerDependencies": {
29 "@t3tools/client-runtime": "*",
30 "@t3tools/shared": "*",
2931 "expo-asset": "*",
3032 "expo-clipboard": "*",
3133 "expo-haptics": "*",
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs+2
......@@ -66,6 +66,7 @@ const colors = {
6666 terraform: "#693acf",
6767 text: "#84848a",
6868 typescript: "#1a85d4",
69 video: "#a631be",
6970 vite: "#a631be",
7071 vscode: "#1a85d4",
7172 vue: "#199f43",
......@@ -83,6 +84,7 @@ const customIcons = {
8384 pnpm: "t3-file-icon-pnpm",
8485 readme: "t3-file-icon-readme",
8586 tsconfig: "t3-file-icon-tsconfig",
87 video: "t3-file-icon-video",
8688};
8789
8890function symbolFromSprite(sprite, id) {
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts+1
......@@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
5151 text: require("../assets/file-icons/pierre_text.png"),
5252 tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
5353 typescript: require("../assets/file-icons/pierre_typescript.png"),
54 video: require("../assets/file-icons/pierre_video.png"),
5455 vite: require("../assets/file-icons/pierre_vite.png"),
5556 vscode: require("../assets/file-icons/pierre_vscode.png"),
5657 vue: require("../assets/file-icons/pierre_vue.png"),
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+28-3
......@@ -1,3 +1,9 @@
1import {
2 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
4} from "@t3tools/client-runtime/markdown-links";
5import { videoMimeType } from "@t3tools/shared/video";
6
17import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
28
39const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
......@@ -253,15 +259,22 @@ function normalizeDestination(value: string): string {
253259 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
254260}
255261
262/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
263export function normalizeNativeMarkdownUrl(value: string): string {
264 return value.startsWith("//") ? `https:${value}` : value;
265}
266
256267function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
257268 try {
258269 const parsed = new URL(href);
259270 if (parsed.protocol.toLowerCase() !== "file:") {
260271 return null;
261272 }
262 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
263 ? parsed.pathname.slice(1)
273 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
274 const rawPath = uncHostname
275 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
264276 : parsed.pathname;
277 const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
265278 return { path, hash: parsed.hash };
266279 } catch {
267280 return null;
......@@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean {
327340 if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
328341 return true;
329342 }
343 if (isConventionalFilePosition(value)) return true;
330344 return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
331345}
332346
......@@ -338,6 +352,7 @@ function fileLabel(value: string): string {
338352
339353export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
340354 const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
355 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
341356 const exactIcon = FILE_ICON_BY_NAME[basename];
342357 if (exactIcon) return exactIcon;
343358 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
......@@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
354369export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
355370 const normalized = normalizeDestination(href);
356371 try {
357 const parsed = new URL(normalized);
372 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
358373 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
359374 return {
360375 kind: "external",
......@@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
399414 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
400415 };
401416}
417
418/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
419export function resolveMarkdownInlineCodePresentation(
420 content: string,
421): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
422 const candidate = inlineCodeFilePathCandidate(content);
423 if (candidate === null) return null;
424 const presentation = resolveMarkdownLinkPresentation(candidate);
425 return presentation.kind === "file" ? presentation : null;
426}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+16-3
......@@ -1,7 +1,11 @@
11import type { MarkdownNode } from "react-native-nitro-markdown/headless";
22
33import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
4import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks";
4import {
5 resolveMarkdownInlineCodePresentation,
6 resolveMarkdownLinkPresentation,
7 type MarkdownFileIcon,
8} from "./markdownLinks";
59
610export interface NativeMarkdownTextRun {
711 readonly text: string;
......@@ -283,8 +287,17 @@ function appendNode(
283287 return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
284288 case "html_inline":
285289 return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
286 case "code_inline":
287 return appendRun(runs, nodeTextContent(node), { ...context, code: true });
290 case "code_inline": {
291 const content = nodeTextContent(node);
292 const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content);
293 return presentation
294 ? appendRun(runs, presentation.label, {
295 ...context,
296 href: presentation.href,
297 fileIcon: presentation.icon,
298 })
299 : appendRun(runs, content, { ...context, code: true });
300 }
288301 case "soft_break":
289302 return appendRun(runs, " ", context);
290303 case "line_break":
apps/mobile/src/components/FilePreview.ios.tsx+13-1
......@@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react";
33import { Alert } from "react-native";
44
55import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6import { MediaImagePreview } from "./MediaImagePreview";
67
78const NativeControls = requireNativeModule<{
89 presentFile(
......@@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{
1415 dismissFile(identifier: string): Promise<void>;
1516}>("T3NativeControls");
1617
17export function FilePreview(props: {
18function NativeFilePreview(props: {
1819 readonly source: ResolvedFilePreviewSource;
1920 readonly onRequestClose: () => void;
2021}) {
......@@ -41,3 +42,14 @@ export function FilePreview(props: {
4142
4243 return null;
4344}
45
46export function FilePreview(props: {
47 readonly source: ResolvedFilePreviewSource;
48 readonly onRequestClose: () => void;
49}) {
50 return props.source.kind === "image" && props.source.actionsSource ? (
51 <MediaImagePreview {...props} />
52 ) : (
53 <NativeFilePreview {...props} />
54 );
55}
apps/mobile/src/components/FilePreview.tsx+2
......@@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing";
44
55import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
66import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7import { MediaImagePreview } from "./MediaImagePreview";
78
89function PdfPreview(props: {
910 readonly source: ResolvedFilePreviewSource;
......@@ -39,6 +40,7 @@ export function FilePreview(props: {
3940 readonly onRequestClose: () => void;
4041}) {
4142 if (props.source.kind === "pdf") return <PdfPreview {...props} />;
43 if (props.source.actionsSource) return <MediaImagePreview {...props} />;
4244 return (
4345 <ImageViewing
4446 images={[{ uri: props.source.uri }]}
apps/mobile/src/components/FilePreviewModal.tsx+12-4
......@@ -5,6 +5,7 @@ import { Alert, Keyboard } from "react-native";
55
66import type { DraftComposerFileAttachment } from "../lib/composerImages";
77import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import type { MediaActionsSource } from "../lib/mediaActions";
89import { useAssetUrlState } from "../state/assets";
910import { usePreparedConnection } from "../state/session";
1011import { FilePreview } from "./FilePreview";
......@@ -14,6 +15,8 @@ export interface ResolvedFilePreviewSource {
1415 readonly uri: string;
1516 readonly name?: string;
1617 readonly sourceIdentifier?: string;
18 readonly srcFragment?: string;
19 readonly actionsSource?: MediaActionsSource;
1720}
1821
1922export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
......@@ -40,13 +43,18 @@ function ResolvedFilePreview(props: {
4043 (connection._tag === "None" || asset._tag === "Failure");
4144 useEffect(() => Keyboard.dismiss(), []);
4245 useEffect(() => {
43 if (uri === null && asset._tag === "Success") setUri(asset.url);
44 }, [uri, asset]);
46 if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? ""));
47 }, [uri, asset, source.srcFragment]);
4548 useEffect(() => {
4649 if (!failed) return;
47 Alert.alert("Could not open preview", "Reconnect to this environment and try again.");
50 Alert.alert(
51 "Could not open preview",
52 connection._tag === "None"
53 ? "Reconnect to this environment and try again."
54 : "The file could not be loaded. It may have been moved or deleted.",
55 );
4856 onRequestClose();
49 }, [failed]);
57 }, [failed, connection._tag]);
5058 useEffect(() => {
5159 if (!("attachment" in source)) return;
5260 const controller = new AbortController();
apps/mobile/src/components/MediaActionsMenu.tsx created+43
......@@ -0,0 +1,43 @@
1import { MenuView } from "@react-native-menu/menu";
2import type { ReactElement } from "react";
3import { Platform, View, type PressableProps } from "react-native";
4
5import type { useMediaActions } from "../lib/mediaActions";
6import { SymbolView } from "./AppSymbol";
7import { ControlPillMenu } from "./ControlPill";
8
9export function MediaActionsMenu(props: {
10 readonly media: ReturnType<typeof useMediaActions>;
11 readonly inModal?: boolean;
12 readonly children?: ReactElement<PressableProps>;
13}) {
14 if (props.media.actions.length === 0) return props.children ?? null;
15 // Android's normal anchored menu lives in the app-root portal, behind native modals.
16 const nativeAndroidMenu = props.inModal && Platform.OS === "android";
17 const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu;
18 return (
19 <Menu
20 title={props.media.title}
21 shouldOpenOnLongPress={props.children !== undefined}
22 actions={props.media.actions.map(({ id, title, disabled }) => ({
23 id,
24 title,
25 attributes: { disabled: disabled ?? false },
26 }))}
27 onPressAction={({ nativeEvent }) => {
28 props.media.actions.find(({ id }) => id === nativeEvent.event)?.run();
29 }}
30 >
31 {props.children ?? (
32 <View
33 accessible
34 accessibilityRole="button"
35 accessibilityLabel="Media actions"
36 className="min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60"
37 >
38 <SymbolView name="ellipsis" size={20} tintColor="#ffffff" type="monochrome" />
39 </View>
40 )}
41 </Menu>
42 );
43}
apps/mobile/src/components/MediaImagePreview.tsx created+61
......@@ -0,0 +1,61 @@
1import { createContext, useContext } from "react";
2import { Pressable, View } from "react-native";
3import ImageViewing from "react-native-image-viewing";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6import { useMediaActions } from "../lib/mediaActions";
7import { AppText } from "./AppText";
8import { SymbolView } from "./AppSymbol";
9import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
10import { MediaActionsMenu } from "./MediaActionsMenu";
11import { MediaSourceCaption } from "./MediaSourceCaption";
12
13type MediaImagePreviewProps = {
14 readonly source: ResolvedFilePreviewSource;
15 readonly onRequestClose: () => void;
16};
17
18const ImagePreviewContext = createContext<MediaImagePreviewProps | null>(null);
19
20function ImagePreviewHeader() {
21 const props = useContext(ImagePreviewContext)!;
22 const insets = useSafeAreaInsets();
23 const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose);
24 return (
25 <View className="bg-black/70" style={{ paddingTop: insets.top }}>
26 <View className="flex-row items-center gap-2 px-3">
27 <AppText className="flex-1 text-base text-white" numberOfLines={2}>
28 {props.source.name ?? "Image"}
29 </AppText>
30 <MediaActionsMenu media={mediaActions} inModal />
31 <Pressable
32 accessibilityRole="button"
33 accessibilityLabel="Close image"
34 onPress={props.onRequestClose}
35 className="min-h-11 min-w-11 items-center justify-center"
36 >
37 <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
38 </Pressable>
39 </View>
40 <MediaSourceCaption source={mediaActions.title} />
41 </View>
42 );
43}
44
45/** Chat and workspace media retain source actions on both platforms; other files use native previews. */
46export function MediaImagePreview(props: MediaImagePreviewProps) {
47 return (
48 <ImagePreviewContext value={props}>
49 <ImageViewing
50 images={[{ uri: props.source.uri }]}
51 imageIndex={0}
52 visible
53 presentationStyle="fullScreen"
54 onRequestClose={props.onRequestClose}
55 swipeToCloseEnabled
56 doubleTapToZoomEnabled
57 HeaderComponent={ImagePreviewHeader}
58 />
59 </ImagePreviewContext>
60 );
61}
apps/mobile/src/components/MediaSourceCaption.tsx created+19
......@@ -0,0 +1,19 @@
1import { ScrollView } from "react-native";
2
3import { AppText } from "./AppText";
4
5/** Keep the original reference readable without letting long URLs displace the preview. */
6export function MediaSourceCaption(props: { readonly source: string | undefined }) {
7 if (!props.source) return null;
8 return (
9 <ScrollView
10 style={{ maxHeight: 88, flexGrow: 0 }}
11 contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 8 }}
12 nestedScrollEnabled
13 >
14 <AppText selectable className="text-xs text-white/70">
15 {props.source}
16 </AppText>
17 </ScrollView>
18 );
19}
apps/mobile/src/components/MediaVideoPlayer.tsx created+187
......@@ -0,0 +1,187 @@
1import { useIsFocused } from "@react-navigation/native";
2import { useEvent } from "expo";
3import { useVideoPlayer, VideoView } from "expo-video";
4import { useEffect, useEffectEvent, useRef, useState } from "react";
5import { ActivityIndicator, AppState, Pressable, View } from "react-native";
6
7import { AppText } from "./AppText";
8import { SymbolView } from "./AppSymbol";
9import { VideoThumbnailImage } from "./VideoThumbnailImage";
10import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
11import { MediaActionsMenu } from "./MediaActionsMenu";
12
13/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */
14function LoadedMediaVideo(props: {
15 readonly uri: string;
16 readonly resolvePlaybackUri?: () => Promise<string | null>;
17 readonly playRequested: boolean;
18 readonly paused: boolean;
19}) {
20 const focused = useIsFocused();
21 const active = useRef(focused && AppState.currentState === "active");
22 const [attempt, setAttempt] = useState(0);
23 // Expo's Android player also reports completed playback as idle.
24 const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending");
25 const player = useVideoPlayer(null, (player) => {
26 player.staysActiveInBackground = false;
27 player.bufferOptions = { preferredForwardBufferDuration: 5 };
28 });
29 const { status } = useEvent(player, "statusChange", { status: player.status });
30 const loadSource = useEffectEvent(async (signal: AbortSignal) => {
31 const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri;
32 if (signal.aborted) return;
33 if (uri === null) throw new Error("Video unavailable");
34 player.pause();
35 await player.replaceAsync({ uri, contentType: "progressive" });
36 if (!signal.aborted && props.playRequested && active.current) player.play();
37 });
38
39 useEffect(() => {
40 active.current = focused && !props.paused && AppState.currentState === "active";
41 if (!active.current) player.pause();
42 const subscription = AppState.addEventListener("change", (state) => {
43 active.current = focused && !props.paused && state === "active";
44 if (!active.current) player.pause();
45 });
46 return () => subscription.remove();
47 }, [focused, player, props.paused]);
48
49 useEffect(() => {
50 const controller = new AbortController();
51 setLoadState("pending");
52 // A renewed signature is used on Retry, not as a reason to reset the native player.
53 void loadSource(controller.signal).then(
54 () => {
55 if (!controller.signal.aborted) setLoadState("complete");
56 },
57 () => {
58 if (!controller.signal.aborted) setLoadState("error");
59 },
60 );
61 return () => controller.abort();
62 }, [player, props.playRequested, attempt]);
63
64 return (
65 <View collapsable={false} style={{ flex: 1 }}>
66 <VideoView
67 player={player}
68 style={{ width: "100%", height: "100%" }}
69 surfaceType="textureView"
70 nativeControls
71 contentFit="contain"
72 fullscreenOptions={{ enable: true }}
73 allowsPictureInPicture={false}
74 />
75 {loadState === "error" || (loadState === "complete" && status === "error") ? (
76 <View className="absolute inset-0 items-center justify-center gap-2 bg-black px-4">
77 <AppText className="text-center text-sm text-white/80">Video unavailable</AppText>
78 <Pressable
79 accessibilityRole="button"
80 accessibilityLabel="Retry video"
81 onPress={() => setAttempt((value) => value + 1)}
82 className="min-h-11 justify-center px-4"
83 >
84 <AppText className="text-sm text-white">Retry</AppText>
85 </Pressable>
86 </View>
87 ) : loadState === "pending" || status === "loading" ? (
88 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
89 <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
90 </View>
91 ) : null}
92 </View>
93 );
94}
95
96interface MediaVideoPlayerProps {
97 readonly uri: string | null;
98 readonly resolvePlaybackUri?: () => Promise<string | null>;
99 readonly name: string;
100 readonly thumbnailKey: string;
101 readonly thumbnailVisible?: boolean;
102 readonly unavailable?: boolean;
103 readonly expanded?: boolean;
104 readonly paused?: boolean;
105 readonly onExpand?: () => void;
106 readonly actionsSource?: MediaActionsSource;
107}
108
109function MediaVideoPlayerContent(props: MediaVideoPlayerProps) {
110 const mediaActions = useMediaActions(props.actionsSource);
111 const [playbackUri, setPlaybackUri] = useState<string | null>(props.expanded ? props.uri : null);
112 // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL.
113 if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri);
114
115 return (
116 <View
117 collapsable={false}
118 className="overflow-hidden rounded-[10px] bg-black"
119 style={props.expanded ? { flex: 1 } : { width: "100%", maxWidth: 480, aspectRatio: 16 / 9 }}
120 >
121 {playbackUri ? (
122 <LoadedMediaVideo
123 uri={props.uri ?? playbackUri}
124 resolvePlaybackUri={props.resolvePlaybackUri}
125 playRequested={!props.expanded}
126 paused={(props.paused ?? false) || mediaActions.sharing}
127 />
128 ) : (
129 <Pressable
130 accessibilityRole="button"
131 accessibilityLabel={`Play ${props.name}`}
132 accessibilityState={{ disabled: props.uri === null || props.unavailable === true }}
133 disabled={props.uri === null || props.unavailable === true}
134 onPress={() => setPlaybackUri(props.uri)}
135 className="flex-1 items-center justify-center gap-2 px-4"
136 >
137 {!props.unavailable ? (
138 <VideoThumbnailImage
139 cacheKey={props.thumbnailKey}
140 source={props.thumbnailVisible === false ? null : props.uri}
141 contentFit="contain"
142 />
143 ) : null}
144 {props.unavailable ? (
145 <AppText className="text-sm text-white/80">Video unavailable</AppText>
146 ) : props.uri === null ? (
147 <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
148 ) : (
149 <>
150 <View className="size-12 items-center justify-center rounded-full bg-black/60">
151 <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" />
152 </View>
153 <AppText
154 className="rounded bg-black/60 px-2 py-1 text-center text-xs text-white"
155 numberOfLines={2}
156 >
157 {props.name}
158 </AppText>
159 </>
160 )}
161 </Pressable>
162 )}
163 {props.onExpand ? (
164 <Pressable
165 accessibilityRole="button"
166 accessibilityLabel={`Expand ${props.name}`}
167 onPress={() => {
168 setPlaybackUri(null);
169 props.onExpand?.();
170 }}
171 className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2"
172 >
173 <AppText className="text-xs text-white">Expand</AppText>
174 </Pressable>
175 ) : null}
176 {props.actionsSource ? (
177 <View className="absolute left-1 top-1">
178 <MediaActionsMenu media={mediaActions} inModal={props.expanded} />
179 </View>
180 ) : null}
181 </View>
182 );
183}
184
185export function MediaVideoPlayer(props: MediaVideoPlayerProps) {
186 return <MediaVideoPlayerContent key={props.thumbnailKey} {...props} />;
187}
apps/mobile/src/components/MediaVideoPreviewModal.tsx created+96
......@@ -0,0 +1,96 @@
1import { useEffect } from "react";
2import { Keyboard, Modal, Pressable, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { useMediaActions } from "../lib/mediaActions";
6import { MediaActionsMenu } from "./MediaActionsMenu";
7import {
8 mediaVideoPreviewUri,
9 mediaVideoThumbnailKey,
10 type MediaVideoPreviewSource,
11} from "../lib/videoPreviewSource";
12import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
13import { usePreparedConnection } from "../state/session";
14import { AppText } from "./AppText";
15import { SymbolView } from "./AppSymbol";
16import { MediaVideoPlayer } from "./MediaVideoPlayer";
17import { MediaSourceCaption } from "./MediaSourceCaption";
18
19/** Media files stream in place. A client-side copy is made only for an explicit share. */
20export function MediaVideoPreviewModal(props: {
21 readonly source: MediaVideoPreviewSource;
22 readonly onRequestClose: () => void;
23}) {
24 const { source } = props;
25 const insets = useSafeAreaInsets();
26 const environmentId = "environmentId" in source ? source.environmentId : null;
27 const connection = usePreparedConnection(environmentId);
28 const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null);
29 const refreshAssetUrl = useRefreshAssetUrl(
30 environmentId,
31 "resource" in source ? source.resource : null,
32 );
33 const resolvePlaybackUri =
34 "resource" in source
35 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
36 : undefined;
37 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
38 const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose);
39 const unavailable =
40 uri === null &&
41 environmentId !== null &&
42 (connection._tag === "None" || asset._tag === "Failure");
43
44 useEffect(() => Keyboard.dismiss(), []);
45 return (
46 <Modal
47 visible
48 animationType="fade"
49 presentationStyle="fullScreen"
50 onRequestClose={props.onRequestClose}
51 statusBarTranslucent
52 navigationBarTranslucent
53 >
54 <View
55 className="flex-1 bg-black"
56 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
57 >
58 <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2">
59 <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}>
60 {source.name}
61 </AppText>
62 <MediaActionsMenu media={mediaActions} inModal />
63 <Pressable
64 accessibilityRole="button"
65 accessibilityLabel="Close video"
66 onPress={props.onRequestClose}
67 className="size-12 items-center justify-center"
68 >
69 <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
70 </Pressable>
71 </View>
72 <MediaSourceCaption source={mediaActions.title} />
73 <MediaVideoPlayer
74 uri={uri}
75 resolvePlaybackUri={resolvePlaybackUri}
76 name={source.name}
77 thumbnailKey={mediaVideoThumbnailKey(source)}
78 unavailable={unavailable}
79 paused={mediaActions.sharing}
80 expanded
81 />
82 <Pressable
83 accessibilityRole="button"
84 accessibilityLabel="Save or share video"
85 disabled={uri === null || mediaActions.sharing}
86 onPress={mediaActions.share}
87 className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4"
88 >
89 <AppText className="font-t3-medium text-base text-white">
90 {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"}
91 </AppText>
92 </Pressable>
93 </View>
94 </Modal>
95 );
96}
apps/mobile/src/components/VideoPreviewModal.ios.tsx+7-3
......@@ -7,9 +7,10 @@ import { Alert, Keyboard } from "react-native";
77import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
88import { useAssetUrlState } from "../state/assets";
99import { usePreparedConnection } from "../state/session";
10import type { VideoPreviewSource } from "./VideoPreviewModal";
10import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource";
11import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal";
1112
12export type { VideoPreviewSource } from "./VideoPreviewModal";
13export type { VideoPreviewSource } from "../lib/videoPreviewSource";
1314
1415const NativeControls = requireNativeModule<{
1516 presentVideo(
......@@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{
2223}>("T3NativeControls");
2324
2425function NativeVideoPreview(props: {
25 readonly source: VideoPreviewSource;
26 readonly source: AttachmentVideoPreviewSource;
2627 readonly onRequestClose: () => void;
2728}) {
2829 const { source } = props;
......@@ -117,5 +118,8 @@ export function VideoPreviewModal(props: {
117118 }, [isFocused, hasSource]);
118119
119120 if (!props.source || !isFocused) return null;
121 if (props.source.type === "media") {
122 return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />;
123 }
120124 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;
121125}
apps/mobile/src/components/VideoPreviewModal.tsx+7-11
......@@ -1,5 +1,4 @@
11import { useIsFocused } from "@react-navigation/native";
2import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts";
32import { videoMimeType } from "@t3tools/shared/video";
43import { useEvent } from "expo";
54import { useVideoPlayer, VideoView } from "expo-video";
......@@ -19,21 +18,15 @@ import {
1918 downloadAttachmentForPreview,
2019 type AttachmentPreviewFile,
2120} from "../lib/attachmentDownload";
22import type { DraftComposerFileAttachment } from "../lib/composerImages";
2321import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
22import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource";
2423import { useAssetUrlState } from "../state/assets";
2524import { usePreparedConnection } from "../state/session";
2625import { SymbolView } from "./AppSymbol";
2726import { AppText } from "./AppText";
27import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal";
2828
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 };
29export type { VideoPreviewSource } from "../lib/videoPreviewSource";
3730
3831function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {
3932 const player = useVideoPlayer(props.file.uri, (player) => {
......@@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {
120113}
121114
122115function OpenVideoPreviewModal(props: {
123 readonly source: VideoPreviewSource;
116 readonly source: AttachmentVideoPreviewSource;
124117 readonly onRequestClose: () => void;
125118}) {
126119 const { source } = props;
......@@ -250,6 +243,9 @@ export function VideoPreviewModal(props: {
250243 }, [isFocused, hasSource, props.onRequestClose]);
251244 const { source } = props;
252245 if (source === null || !isFocused) return null;
246 if (source.type === "media") {
247 return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />;
248 }
253249 const key =
254250 source.type === "local"
255251 ? `local:${source.attachment.id}:${source.attachment.fileUri}`
apps/mobile/src/components/VideoThumbnailImage.tsx+2-1
......@@ -11,6 +11,7 @@ import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails
1111export function VideoThumbnailImage(props: {
1212 readonly cacheKey: string;
1313 readonly source: string | DraftComposerFileAttachment | null;
14 readonly contentFit?: "cover" | "contain";
1415}) {
1516 const { cacheKey, source } = props;
1617 const isFocused = useIsFocused();
......@@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: {
3738 <Image
3839 source={thumbnail}
3940 style={StyleSheet.absoluteFill}
40 contentFit="cover"
41 contentFit={props.contentFit ?? "cover"}
4142 recyclingKey={cacheKey}
4243 accessible={false}
4344 />
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+116-19
......@@ -1,7 +1,7 @@
11import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
22import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import type { MenuAction } from "@react-native-menu/menu";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
55import { ActivityIndicator, Platform, View } from "react-native";
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
......@@ -11,6 +11,9 @@ import {
1111 type ProjectReadFileResult,
1212 ThreadId,
1313} from "@t3tools/contracts";
14import { videoMimeType } from "@t3tools/shared/video";
15import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
16import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1417
1518import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1619import { SymbolView } from "../../components/AppSymbol";
......@@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
2427import { isPdfFile } from "../../lib/filePreview";
2528import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2629import { useUniwindTheme } from "../../lib/useUniwindTheme";
30import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
31import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
2732import { useThreadSelection } from "../../state/use-thread-selection";
2833import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
2934import { useEnvironmentQuery } from "../../state/query";
......@@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";
4752import { SourceFileSurface } from "./SourceFileSurface";
4853import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
4954import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
55import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
5056import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
5157import {
5258 basename,
......@@ -54,8 +60,9 @@ import {
5460 isImagePreviewFile,
5561 isMarkdownPreviewFile,
5662 isSvgImagePreviewFile,
63 isVideoPreviewFile,
5764} from "./filePath";
58import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl";
65import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";
5966
6067type FileViewMode = "preview" | "source";
6168
......@@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null {
8491}
8592
8693function defaultViewMode(path: string | null): FileViewMode {
87 return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))
94 return path !== null &&
95 (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path))
8896 ? "preview"
8997 : "source";
9098}
......@@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode {
92100function FileContent(props: {
93101 readonly activeMode: FileViewMode;
94102 readonly previewUri: string | null;
103 readonly previewUnavailable: boolean;
104 readonly videoSource: MediaVideoPreviewSource | null;
105 readonly mediaSource?: MediaActionsSource;
106 readonly resolveVideoUri: () => Promise<string | null>;
95107 readonly fileContents: string | null;
96108 readonly fileError: string | null;
97109 readonly relativePath: string;
......@@ -99,10 +111,25 @@ function FileContent(props: {
99111 readonly truncated: boolean;
100112 readonly onRefresh?: () => Promise<void> | void;
101113}) {
114 // Reopening a mutable host file must not reuse a poster from an earlier visit.
115 const thumbnailInstanceId = useId();
102116 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
103117 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
104118 const isImageFile = isImagePreviewFile(props.relativePath);
105119
120 if (isVideoPreviewFile(props.relativePath)) {
121 return (
122 <WorkspaceFileVideoPreview
123 name={basename(props.relativePath)}
124 thumbnailKey={`workspace-video:${thumbnailInstanceId}`}
125 uri={props.previewUri}
126 source={props.videoSource}
127 resolvePlaybackUri={props.resolveVideoUri}
128 unavailable={props.previewUnavailable}
129 />
130 );
131 }
132
106133 if (props.activeMode === "preview" && isImageFile) {
107134 if (isSvgImagePreviewFile(props.relativePath)) {
108135 return <WorkspaceFileWebPreview uri={props.previewUri} />;
......@@ -111,6 +138,7 @@ function FileContent(props: {
111138 <WorkspaceFileImagePreview
112139 accessibilityLabel={basename(props.relativePath)}
113140 uri={props.previewUri}
141 actionsSource={props.mediaSource}
114142 />
115143 );
116144 }
......@@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
489517 readonly mode: FileViewMode;
490518 } | null>(null);
491519 const [previewRevision, setPreviewRevision] = useState(0);
520 const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]);
492521 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
522 const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath);
523 const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath);
524 const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath);
495525 const canPreview =
496 relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile);
526 relativePath !== null &&
527 (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile);
497528 const activeMode =
498529 relativePath !== null && modeOverride?.path === relativePath
499530 ? modeOverride.mode
500531 : defaultViewMode(relativePath);
501 const resolvedActiveMode = canPreview ? activeMode : "source";
502 const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;
503 const assetPreviewUri = useWorkspaceFileAssetUrl({
532 const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source";
533 const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null;
534 const assetPreview = useWorkspaceFileAssetUrlState({
504535 cwd,
505536 environmentId,
506537 relativePath: assetPreviewPath,
507538 threadId,
508539 });
540 const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null;
541 const mediaSource = useMemo<MediaActionsSource | undefined>(
542 () =>
543 environmentId !== null &&
544 threadId !== null &&
545 relativePath !== null &&
546 assetPreview.resource !== null &&
547 "path" in assetPreview.resource &&
548 typeof assetPreview.resource.path === "string" &&
549 (isImageFile || isVideoFile)
550 ? {
551 reference: mediaFileReference(assetPreview.resource.path, cwd),
552 name: basename(relativePath),
553 mimeType:
554 mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ??
555 "application/octet-stream",
556 environmentId,
557 threadId,
558 resource: assetPreview.resource,
559 }
560 : undefined,
561 [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId],
562 );
563 const mediaActions = useMediaActions(mediaSource);
564 const videoSource = useMemo<MediaVideoPreviewSource | null>(
565 () =>
566 environmentId !== null &&
567 relativePath !== null &&
568 assetPreview.resource?._tag === "media-file"
569 ? {
570 type: "media",
571 environmentId,
572 resource: assetPreview.resource,
573 name: basename(relativePath),
574 mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4",
575 actionsSource: mediaSource,
576 }
577 : null,
578 [assetPreview.resource, environmentId, relativePath, mediaSource],
579 );
509580 const previewUri =
510581 assetPreviewUri === null || previewRevision === 0
511582 ? assetPreviewUri
512583 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
513584 const needsFileContents =
514585 relativePath !== null &&
586 !isVideoFile &&
515587 (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
516588 const fileQuery = useEnvironmentQuery(
517589 environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
......@@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
562634
563635 const fileMenuActions = useMemo(() => {
564636 if (relativePath === null) return [];
565 const canToggleMode = canPreview && !isImageFile;
637 const canToggleMode = canPreview && !isImageFile && !isVideoFile;
566638 return [
567639 canToggleMode
568640 ? ({
......@@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
582654 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
583655 } as const)
584656 : null,
585 {
586 id: "copy-path",
587 title: "Copy path",
588 icon: "doc.on.doc",
589 inline: false,
590 onPress: () => copyTextWithHaptic(relativePath),
591 } as const,
657 ...(mediaSource
658 ? mediaActions.actions
659 .filter(({ id }) => id !== "open-file")
660 .map((action) => ({
661 id: action.id,
662 title: action.title,
663 icon:
664 action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const),
665 inline: false,
666 onPress: action.run,
667 }))
668 : [
669 {
670 id: "copy-path",
671 title: "Copy path",
672 icon: "doc.on.doc",
673 inline: false,
674 onPress: () => copyTextWithHaptic(relativePath),
675 } as const,
676 ]),
592677 isPdfFile({ name: relativePath }) && previewUri !== null
593678 ? ({
594679 id: "open-pdf",
......@@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
612697 onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),
613698 } as const)
614699 : null,
615 resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)
700 resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile)
616701 ? ({
617702 id: "refresh",
618703 title: "Refresh",
619704 icon: "arrow.clockwise",
620705 inline: false,
621 onPress: () => setPreviewRevision((current) => current + 1),
706 onPress: async () => {
707 if (isVideoFile) await assetPreview.refresh();
708 setPreviewRevision((current) => current + 1);
709 },
622710 } as const)
623711 : null,
624712 ].filter((action) => action !== null);
625713 }, [
626714 assetPreviewUri,
715 assetPreview.refresh,
627716 previewUri,
628717 canPreview,
629718 isBrowserFile,
630719 isImageFile,
720 isVideoFile,
631721 relativePath,
632722 resolvedActiveMode,
723 mediaSource,
724 mediaActions.actions,
633725 ]);
634726
635727 const androidFileMenuActions = useMemo<MenuAction[]>(
......@@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
782874 </NativeHeaderToolbar.Menu>
783875 </NativeHeaderToolbar>
784876 <FileContent
877 key={previewKey}
785878 activeMode={resolvedActiveMode}
786879 previewUri={previewUri}
880 previewUnavailable={assetPreview._tag === "Failure"}
881 videoSource={videoSource}
882 mediaSource={mediaSource}
883 resolveVideoUri={assetPreview.refresh}
787884 fileContents={fileData?.contents ?? null}
788885 fileError={fileQuery.error}
789886 initialLine={targetLine}
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+13-1
......@@ -8,14 +8,18 @@ import { EmptyState } from "../../components/EmptyState";
88import { workspaceFileImageAtom } from "./workspace-file-image-cache";
99import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
1010import { PresentationSource } from "../../components/NativePresentation";
11import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
12import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1113
1214function ResolvedWorkspaceFileImagePreview(props: {
1315 readonly accessibilityLabel: string;
1416 readonly uri: string;
17 readonly actionsSource?: MediaActionsSource;
1518}) {
1619 const [loadError, setLoadError] = useState<string | null>(null);
1720 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
1821 const sourceIdentifier = useId();
22 const mediaActions = useMediaActions(props.actionsSource);
1923 const imageSource = useMemo(
2024 () => ({ uri: props.uri, cache: "force-cache" as const }),
2125 [props.uri],
......@@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
3438 uri: props.uri,
3539 name: props.accessibilityLabel,
3640 sourceIdentifier,
41 actionsSource: props.actionsSource,
3742 })
3843 }
3944 >
......@@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: {
5055 />
5156 </PresentationSource>
5257 </Pressable>
53
5458 {loadError !== null ? (
5559 <View className="absolute inset-0 items-center justify-center bg-card px-6">
5660 <EmptyState title="Image unavailable" detail={loadError} />
5761 </View>
5862 ) : null}
5963
64 <View className="absolute right-2 top-2">
65 <MediaActionsMenu media={mediaActions} />
66 </View>
67
6068 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
6169 </View>
6270 );
......@@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
6573function CachedWorkspaceFileImagePreview(props: {
6674 readonly accessibilityLabel: string;
6775 readonly uri: string;
76 readonly actionsSource?: MediaActionsSource;
6877}) {
6978 const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]);
7079 const imageResult = useAtomValue(imageAtom);
......@@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: {
93102 <ResolvedWorkspaceFileImagePreview
94103 accessibilityLabel={props.accessibilityLabel}
95104 uri={imageResult.value}
105 actionsSource={props.actionsSource}
96106 />
97107 );
98108}
......@@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: {
100110export function WorkspaceFileImagePreview(props: {
101111 readonly accessibilityLabel: string;
102112 readonly uri: string | null;
113 readonly actionsSource?: MediaActionsSource;
103114}) {
104115 if (props.uri === null) {
105116 return (
......@@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: {
116127 <CachedWorkspaceFileImagePreview
117128 accessibilityLabel={props.accessibilityLabel}
118129 uri={props.uri}
130 actionsSource={props.actionsSource}
119131 />
120132 );
121133}
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx created+47
......@@ -0,0 +1,47 @@
1import { useState } from "react";
2import { View } from "react-native";
3
4import { EmptyState } from "../../components/EmptyState";
5import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
6import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
7import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
8
9/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */
10export function WorkspaceFileVideoPreview(props: {
11 readonly name: string;
12 readonly thumbnailKey: string;
13 readonly uri: string | null;
14 readonly source: MediaVideoPreviewSource | null;
15 readonly resolvePlaybackUri: () => Promise<string | null>;
16 readonly unavailable: boolean;
17}) {
18 const [preview, setPreview] = useState<VideoPreviewSource | null>(null);
19 const uri = props.uri;
20
21 if (props.unavailable) {
22 return (
23 <View className="flex-1 items-center justify-center bg-sheet px-6">
24 <EmptyState
25 title="Video unavailable"
26 detail="This file may be missing, unsupported, or unavailable on this environment."
27 />
28 </View>
29 );
30 }
31
32 return (
33 <View className="flex-1 items-center justify-center bg-sheet p-4">
34 <MediaVideoPlayer
35 uri={uri}
36 resolvePlaybackUri={props.resolvePlaybackUri}
37 name={props.name}
38 thumbnailKey={props.thumbnailKey}
39 actionsSource={props.source?.actionsSource}
40 onExpand={
41 uri === null || props.source === null ? undefined : () => setPreview(props.source)
42 }
43 />
44 <VideoPreviewModal source={preview} onRequestClose={() => setPreview(null)} />
45 </View>
46 );
47}
apps/mobile/src/features/files/filePath.ts+5
......@@ -1,6 +1,7 @@
11import {
22 isWorkspaceBrowserPreviewPath,
33 isWorkspaceImagePreviewPath,
4 isWorkspaceVideoPreviewPath,
45} from "@t3tools/shared/filePreview";
56
67export interface FileBreadcrumb {
......@@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean {
9596 return isWorkspaceImagePreviewPath(path);
9697}
9798
99export function isVideoPreviewFile(path: string): boolean {
100 return isWorkspaceVideoPreviewPath(path);
101}
102
98103export function isSvgImagePreviewFile(path: string): boolean {
99104 return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
100105}
apps/mobile/src/features/files/preload-workspace-file.ts+6-2
......@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
33
44import { appAtomRegistry } from "../../state/atom-registry";
55import { projectEnvironment } from "../../state/projects";
6import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath";
6import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath";
77import { prepareSourceFileDocument } from "./source-file-document";
88import { sourceHighlightAtom } from "./sourceHighlightingState";
99import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
......@@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: {
2525 readonly relativePath: string;
2626 readonly theme: ReviewDiffTheme;
2727}): void {
28 if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) {
28 if (
29 isBrowserPreviewFile(input.relativePath) ||
30 isImagePreviewFile(input.relativePath) ||
31 isVideoPreviewFile(input.relativePath)
32 ) {
2933 return;
3034 }
3135
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+17-13
......@@ -1,10 +1,10 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
22import { useMemo } from "react";
33
4import { useAssetUrl } from "../../state/assets";
5import { resolveWorkspaceFilePath } from "./filePath";
4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
66
7export function useWorkspaceFileAssetUrl(props: {
7export function useWorkspaceFileAssetUrlState(props: {
88 readonly cwd: string | null;
99 readonly environmentId: EnvironmentId | null;
1010 readonly relativePath: string | null;
......@@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: {
1818 [props.cwd, props.relativePath],
1919 );
2020
21 return useAssetUrl(
22 props.environmentId,
23 absolutePath !== null && props.threadId !== null
24 ? {
25 _tag: "workspace-file",
26 threadId: props.threadId,
27 path: absolutePath,
28 }
29 : null,
21 const resource = useMemo<AssetResource | null>(
22 () =>
23 absolutePath !== null && props.threadId !== null
24 ? {
25 _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file",
26 threadId: props.threadId,
27 path: absolutePath,
28 }
29 : null,
30 [absolutePath, props.threadId],
3031 );
32 const state = useAssetUrlState(props.environmentId, resource);
33 const refresh = useRefreshAssetUrl(props.environmentId, resource);
34 return { ...state, resource, refresh };
3135}
apps/mobile/src/features/threads/ThreadFeed.tsx+262-96
......@@ -1,6 +1,6 @@
11import * as Haptics from "expo-haptics";
22import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";
3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
44import type {
55 AssetResource,
66 ChatAttachment,
......@@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
3333import { HeaderHeightContext } from "@react-navigation/elements";
3434import { useFocusEffect, useNavigation } from "@react-navigation/native";
3535import {
36 createContext,
3637 memo,
3738 useCallback,
3839 useContext,
......@@ -97,6 +98,15 @@ import {
9798import { AppText as Text } from "../../components/AppText";
9899import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
99100import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
101import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
102import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
103import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
104import { MediaActionsMenu } from "../../components/MediaActionsMenu";
105import {
106 mediaVideoPreviewUri,
107 mediaVideoThumbnailKey,
108 type MediaVideoPreviewSource,
109} from "../../lib/videoPreviewSource";
100110import { CopyTextButton } from "../../components/CopyTextButton";
101111import {
102112 parseReviewCommentMessageSegments,
......@@ -123,7 +133,11 @@ import {
123133import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
124134import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
125135import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
126import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
136import {
137 normalizeNativeMarkdownUrl,
138 resolveMarkdownInlineCodePresentation,
139 resolveMarkdownLinkPresentation,
140} from "@t3tools/mobile-markdown-text/links";
127141import {
128142 deriveThreadFeedPresentation,
129143 type ThreadFeedEntry,
......@@ -143,7 +157,12 @@ import {
143157 WORK_GROUP_TOGGLE_HEIGHT,
144158} from "./thread-work-log";
145159import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
146import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";
160import {
161 assetEnvironment,
162 useAssetUrl,
163 useAssetUrlState,
164 useRefreshAssetUrl,
165} from "../../state/assets";
147166import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
148167import { usePreparedConnection } from "../../state/session";
149168import * as Option from "effect/Option";
......@@ -272,6 +291,7 @@ function MessageAttachmentFile(props: {
272291}) {
273292 const sourceIdentifier = useId();
274293 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
294 refresh: true,
275295 reportFailure: false,
276296 });
277297 const preparedConnection = usePreparedConnection(props.environmentId);
......@@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: {
453473 readonly sourceKey: string;
454474 readonly unavailable: boolean;
455475 readonly alt: string | null;
476 readonly actionsSource?: MediaActionsSource;
456477 readonly onPressPreview: (source: FilePreviewSource) => void;
457478}) {
458479 const sourceIdentifier = useId();
480 const mediaActions = useMediaActions(props.actionsSource);
459481 const [availableWidth, setAvailableWidth] = useState(0);
460482 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
461483 const [failedUri, setFailedUri] = useState<string | null>(null);
......@@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: {
498520 ) : (
499521 <ActivityIndicator />
500522 )}
523 {props.actionsSource ? (
524 <View className="absolute right-1 top-1">
525 <MediaActionsMenu media={mediaActions} />
526 </View>
527 ) : null}
501528 </View>
502529 ) : (
503530 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
504 <Pressable
505 accessibilityRole="imagebutton"
506 accessibilityLabel={props.alt ?? "Markdown image"}
507 onPress={() =>
508 props.onPressPreview({
509 kind: "image",
510 uri: props.uri!,
511 name: props.alt ?? "Image",
512 sourceIdentifier,
513 })
514 }
515 style={{ alignSelf: "flex-start" }}
516 >
517 <View
518 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
519 style={{
520 ...frameStyle,
521 }}
522 >
523 <ThreadMarkdownImageRequest
524 key={props.uri}
525 uri={props.uri}
526 onLoad={setSourceSize}
527 onError={() => setFailedUri(props.uri)}
528 />
529 </View>
530 </Pressable>
531 <View>
532 <MediaActionsMenu media={mediaActions}>
533 <Pressable
534 accessibilityRole="imagebutton"
535 accessibilityLabel={props.alt ?? "Markdown image"}
536 onPress={() =>
537 props.onPressPreview({
538 kind: "image",
539 uri: props.uri!,
540 name: props.alt ?? "Image",
541 sourceIdentifier,
542 actionsSource: props.actionsSource,
543 })
544 }
545 style={{ alignSelf: "flex-start" }}
546 >
547 <View
548 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
549 style={{
550 ...frameStyle,
551 }}
552 >
553 <ThreadMarkdownImageRequest
554 key={props.uri}
555 uri={props.uri}
556 onLoad={setSourceSize}
557 onError={() => setFailedUri(props.uri)}
558 />
559 </View>
560 </Pressable>
561 </MediaActionsMenu>
562 {props.actionsSource ? (
563 <View className="absolute right-1 top-1">
564 <MediaActionsMenu media={mediaActions} />
565 </View>
566 ) : null}
567 </View>
531568 </PresentationSource>
532569 )}
533570 {props.alt ? (
......@@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: {
574611/** Environment-hosted image that loads through a signed asset URL. */
575612function ThreadMarkdownImage(props: {
576613 readonly environmentId: EnvironmentId;
577 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
614 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
578615 readonly alt: string | null;
579616 readonly srcFragment?: string;
617 readonly actionsSource?: MediaActionsSource;
580618 readonly onPressPreview: (source: FilePreviewSource) => void;
581619}) {
582620 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
......@@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: {
591629 }
592630 unavailable={assetUrl._tag === "Failure"}
593631 alt={props.alt}
632 actionsSource={props.actionsSource}
594633 onPressPreview={props.onPressPreview}
595634 />
596635 );
597636}
598637
638const ThreadMediaVisibleContext = createContext(false);
639// LegendList only computes hook visibility when the list has a viewability config.
640const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
641
642function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
643 const [visible, setVisible] = useState(false);
644 useViewabilityAmount<ThreadFeedEntry>(
645 useCallback((token) => setVisible(token.sizeVisible > 0), []),
646 );
647 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
648}
649
650function ThreadMarkdownVideo(props: {
651 readonly source: MediaVideoPreviewSource;
652 readonly onExpand: (source: MediaVideoPreviewSource) => void;
653}) {
654 const { source } = props;
655 const visible = useContext(ThreadMediaVisibleContext);
656 const thumbnailKey = mediaVideoThumbnailKey(source);
657 const asset = useAssetUrlState(
658 "environmentId" in source ? source.environmentId : null,
659 "resource" in source ? source.resource : null,
660 );
661 const refreshAssetUrl = useRefreshAssetUrl(
662 "environmentId" in source ? source.environmentId : null,
663 "resource" in source ? source.resource : null,
664 );
665 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
666 return (
667 <MediaVideoPlayer
668 key={thumbnailKey}
669 uri={uri}
670 resolvePlaybackUri={
671 "resource" in source
672 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
673 : undefined
674 }
675 name={source.name}
676 thumbnailKey={thumbnailKey}
677 thumbnailVisible={visible}
678 unavailable={"resource" in source && asset._tag === "Failure"}
679 actionsSource={source.actionsSource}
680 onExpand={() => props.onExpand(source)}
681 />
682 );
683}
684
599685function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
600686 return (
601687 <ThreadMarkdownImageView
......@@ -636,6 +722,7 @@ interface ReviewCommentColors {
636722}
637723
638724const failedMarkdownFaviconHosts = new Set<string>();
725const MarkdownLinkLabelContext = createContext(false);
639726const markdownLinkStyles = StyleSheet.create({
640727 inlineIcon: {
641728 width: 14,
......@@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
653740 readonly color: string;
654741 readonly host: string;
655742 readonly href: string;
743 readonly onPress: (href: string) => void;
656744}) {
657745 const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));
658746
659747 return (
660748 <NativeText
661749 className="font-sans"
662 onPress={() => {
663 void tryOpenExternalUrl(props.href, "markdown-link");
664 }}
750 onPress={() => props.onPress(props.href)}
665751 style={{
666752 color: props.color,
667753 textDecorationLine: "none",
......@@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
686772 );
687773});
688774
775function MarkdownInlineCode(props: {
776 readonly content: string;
777 readonly textColor: string;
778 readonly codeColor: string;
779 readonly fontSize: number;
780 readonly lineHeight: number;
781 readonly onLinkPress: (href: string) => void;
782}) {
783 const insideLink = useContext(MarkdownLinkLabelContext);
784 const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content);
785 return (
786 <NativeText
787 className={presentation ? "font-t3-bold" : "font-mono"}
788 onPress={presentation ? () => props.onLinkPress(presentation.href) : undefined}
789 style={{
790 color: presentation ? props.textColor : props.codeColor,
791 fontSize: props.fontSize,
792 lineHeight: props.lineHeight,
793 }}
794 >
795 {presentation ? (
796 <Image
797 source={markdownFileIconSource(presentation.icon)}
798 style={markdownLinkStyles.inlineIcon}
799 />
800 ) : null}
801 {presentation?.label ?? props.content}
802 </NativeText>
803 );
804}
805
689806const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
690807 CodexArtifactTemplate["artifactKind"],
691808 AppSymbolName
......@@ -1078,30 +1195,35 @@ function useMarkdownStyles(
10781195 }
10791196 if (presentation.kind === "external") {
10801197 return (
1081 <MarkdownExternalLink
1082 href={presentation.href}
1083 host={presentation.host}
1084 color={markdownLinkColor}
1085 >
1086 {children}
1087 </MarkdownExternalLink>
1198 <MarkdownLinkLabelContext.Provider value>
1199 <MarkdownExternalLink
1200 href={presentation.href}
1201 host={presentation.host}
1202 color={markdownLinkColor}
1203 onPress={onLinkPress}
1204 >
1205 {children}
1206 </MarkdownExternalLink>
1207 </MarkdownLinkLabelContext.Provider>
10881208 );
10891209 }
10901210 const linkHref = presentation.href;
10911211 return (
1092 <NativeText
1093 className="underline"
1094 onPress={
1095 linkHref
1096 ? () => {
1097 void tryOpenExternalUrl(linkHref, "markdown-link");
1098 }
1099 : undefined
1100 }
1101 style={{ color: markdownLinkColor }}
1102 >
1103 {children}
1104 </NativeText>
1212 <MarkdownLinkLabelContext.Provider value>
1213 <NativeText
1214 className="underline"
1215 onPress={
1216 linkHref
1217 ? () => {
1218 void tryOpenExternalUrl(linkHref, "markdown-link");
1219 }
1220 : undefined
1221 }
1222 style={{ color: markdownLinkColor }}
1223 >
1224 {children}
1225 </NativeText>
1226 </MarkdownLinkLabelContext.Provider>
11051227 );
11061228 },
11071229 list: ({ node, Renderer, ordered = false, start = 1 }) => (
......@@ -1144,21 +1266,16 @@ function useMarkdownStyles(
11441266 title: node.title ?? null,
11451267 }) ?? undefined)
11461268 : undefined,
1147 code_inline: ({ content }) => {
1148 const value = content ?? "";
1149 return (
1150 <NativeText
1151 className="font-mono"
1152 style={{
1153 color: inlineCodeTextColor,
1154 fontSize: markdownFontSizes.codeBlockFontSize,
1155 lineHeight: markdownFontSizes.bodyLineHeight,
1156 }}
1157 >
1158 {value}
1159 </NativeText>
1160 );
1161 },
1269 code_inline: ({ content }) => (
1270 <MarkdownInlineCode
1271 content={content ?? ""}
1272 textColor={inlineTextColor}
1273 codeColor={inlineCodeTextColor}
1274 fontSize={markdownFontSizes.codeBlockFontSize}
1275 lineHeight={markdownFontSizes.bodyLineHeight}
1276 onLinkPress={onLinkPress}
1277 />
1278 ),
11621279 ...(preserveSoftBreaks
11631280 ? {
11641281 soft_break: () => <NativeText>{"\n"}</NativeText>,
......@@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19542071 path: relativePath.split("/").filter((segment) => segment.length > 0),
19552072 ...(presentation.line ? { line: String(presentation.line) } : {}),
19562073 });
2074 return;
2075 }
2076 }
2077
2078 const media = resolveMarkdownMediaPreview(href, {
2079 environmentId: props.environmentId,
2080 threadId: props.threadId,
2081 workspaceRoot: props.workspaceRoot,
2082 });
2083 if (media) {
2084 void Haptics.selectionAsync();
2085 if (media.kind === "video") {
2086 setExpandedVideo((current) => current ?? media.source);
2087 } else {
2088 setExpandedFile((current) => current ?? media.source);
19572089 }
19582090 return;
19592091 }
19602092
1961 if (presentation.href) {
2093 if (presentation.kind !== "file" && presentation.href) {
19622094 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
19632095 setExpandedFile(
19642096 (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },
......@@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19722104 );
19732105 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
19742106 (image) => {
2107 const media = resolveMarkdownMediaPreview(image.href, {
2108 environmentId: props.environmentId,
2109 threadId: props.threadId,
2110 workspaceRoot: props.workspaceRoot,
2111 imageEmbed: true,
2112 });
2113 if (media?.kind === "video") {
2114 return (
2115 <ThreadMarkdownVideo
2116 key={image.href}
2117 source={{ ...media.source, name: image.alt ?? media.source.name }}
2118 onExpand={(source) => setExpandedVideo((current) => current ?? source)}
2119 />
2120 );
2121 }
19752122 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
19762123 if (imageSource._tag === "Direct") {
19772124 return (
19782125 <ThreadMarkdownImageView
1979 uri={imageSource.uri}
2126 uri={normalizeNativeMarkdownUrl(imageSource.uri)}
19802127 sourceKey={imageSource.uri}
19812128 unavailable={false}
19822129 alt={image.alt}
2130 actionsSource={media?.source.actionsSource}
19832131 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
19842132 />
19852133 );
......@@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19912139 <ThreadMarkdownImage
19922140 environmentId={props.environmentId}
19932141 resource={{
1994 _tag: "workspace-file",
2142 _tag: "media-file",
19952143 threadId: props.threadId,
19962144 path: imageSource.path,
19972145 }}
19982146 alt={image.alt}
19992147 srcFragment={markdownImageSourceFragment(image.href)}
2148 actionsSource={media?.source.actionsSource}
20002149 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
20012150 />
20022151 );
......@@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20092158 threadId: props.threadId,
20102159 workspaceRoot: props.workspaceRoot,
20112160 });
2161 const media = viewedImage
2162 ? resolveMarkdownMediaPreview(image.href, {
2163 environmentId: props.environmentId,
2164 threadId: props.threadId,
2165 workspaceRoot: props.workspaceRoot,
2166 imageEmbed: true,
2167 })
2168 : null;
2169 const actionsSource = media?.source.actionsSource;
20122170 return viewedImage ? (
20132171 <ThreadMarkdownImage
20142172 environmentId={props.environmentId}
20152173 resource={viewedImage.resource}
20162174 alt={viewedImage.alt}
20172175 srcFragment={viewedImage.srcFragment}
2176 actionsSource={
2177 actionsSource && "resource" in actionsSource
2178 ? { ...actionsSource, resource: viewedImage.resource }
2179 : undefined
2180 }
20182181 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
20192182 />
20202183 ) : null;
......@@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24382601 key={info.item.id}
24392602 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
24402603 >
2441 {renderFeedEntry(info, {
2442 environmentId: props.environmentId,
2443 copiedRowId,
2444 expandedWorkRows,
2445 terminalAssistantMessageIds,
2446 unsettledTurnId,
2447 onCopyWorkRow,
2448 onToggleWorkGroup,
2449 onToggleWorkRow,
2450 onToggleTurnFold,
2451 onPressPreview,
2452 onPressVideo,
2453 onMarkdownLinkPress,
2454 renderMarkdownImage,
2455 renderViewedImage,
2456 iconSubtleColor,
2457 userBubbleColor,
2458 markdownStyles,
2459 reviewCommentColors,
2460 reviewCommentBubbleWidth,
2461 userBubbleMaxWidth,
2462 skills: props.skills,
2463 onUseArtifactTemplate: props.onUseArtifactTemplate,
2464 })}
2604 <ThreadMediaVisibility>
2605 {renderFeedEntry(info, {
2606 environmentId: props.environmentId,
2607 copiedRowId,
2608 expandedWorkRows,
2609 terminalAssistantMessageIds,
2610 unsettledTurnId,
2611 onCopyWorkRow,
2612 onToggleWorkGroup,
2613 onToggleWorkRow,
2614 onToggleTurnFold,
2615 onPressPreview,
2616 onPressVideo,
2617 onMarkdownLinkPress,
2618 renderMarkdownImage,
2619 renderViewedImage,
2620 iconSubtleColor,
2621 userBubbleColor,
2622 markdownStyles,
2623 reviewCommentColors,
2624 reviewCommentBubbleWidth,
2625 userBubbleMaxWidth,
2626 skills: props.skills,
2627 onUseArtifactTemplate: props.onUseArtifactTemplate,
2628 })}
2629 </ThreadMediaVisibility>
24652630 </Animated.View>
24662631 ),
24672632 [
......@@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25862751 data={presentedFeed}
25872752 extraData={listAppearanceData}
25882753 renderItem={renderItem}
2754 viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG}
25892755 keyExtractor={(entry) => entry.id}
25902756 getItemType={(entry) =>
25912757 entry.type === "message" ? `message:${entry.message.role}` : entry.type
apps/mobile/src/lib/markdownLinks.test.ts+23-6
......@@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test";
33import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
44
55describe("resolveMarkdownLinkPresentation", () => {
6 it("treats protocol-relative media as an external URL, not a filesystem path", () => {
7 expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({
8 kind: "external",
9 href: "https://cdn.example.com/clip.mp4?sig=a%2fb#t=2",
10 host: "cdn.example.com",
11 });
12 });
13
14 it("separates encoded filename characters from a video playback fragment", () => {
15 expect(resolveMarkdownLinkPresentation("/tmp/clip%23one.mp4#t=2")).toMatchObject({
16 path: "/tmp/clip#one.mp4",
17 label: "clip#one.mp4",
18 icon: "video",
19 });
20 });
21
622 it("extracts external link hosts", () => {
723 expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({
824 kind: "external",
......@@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => {
1127 });
1228 });
1329
14 it("renders file URLs as basename pills with positions", () => {
15 expect(
16 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),
17 ).toEqual({
30 it.each([
31 ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"],
32 ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"],
33 ])("preserves the file URL path and position for %s", (href, path) => {
34 expect(resolveMarkdownLinkPresentation(href)).toEqual({
1835 kind: "file",
19 href: "file:///Users/julius/project/src/main.ts#L42C7",
36 href,
2037 icon: "typescript",
2138 label: "main.ts:42:7",
22 path: "/Users/julius/project/src/main.ts",
39 path,
2340 line: 42,
2441 column: 7,
2542 });
apps/mobile/src/lib/markdownMedia.test.ts created+77
......@@ -0,0 +1,77 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveMarkdownMediaPreview } from "./markdownMedia";
5
6const input = {
7 environmentId: EnvironmentId.make("environment-1"),
8 threadId: ThreadId.make("thread-1"),
9 workspaceRoot: "/repo",
10};
11
12describe("resolveMarkdownMediaPreview", () => {
13 it("decodes remote filenames once without changing the authored URL", () => {
14 const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2";
15 expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({
16 kind: "video",
17 source: {
18 uri: href,
19 actionsSource: {
20 name: "clip one%20.mp4",
21 mimeType: "video/mp4",
22 reference: { kind: "url", url: href },
23 },
24 },
25 });
26 });
27
28 it("provides extensionless image actions only for image embeds", () => {
29 const href = "https://cdn.example.com/render?id=42";
30 expect(resolveMarkdownMediaPreview(href, input)).toBeNull();
31 expect(resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true })).toMatchObject({
32 kind: "image",
33 source: { actionsSource: { reference: { kind: "url", url: href }, mimeType: "image/*" } },
34 });
35 });
36
37 it.each([
38 ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"],
39 ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"],
40 ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"],
41 ["file://server/share/frame.png", "\\\\server\\share\\frame.png"],
42 ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"],
43 ])("keeps encoded filename and UNC semantics for %s", (href, path) => {
44 expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({
45 kind: "image",
46 source: {
47 resource: { path },
48 actionsSource: { reference: { kind: "file", path } },
49 },
50 });
51 });
52
53 it("separates a video playback fragment from literal filename characters", () => {
54 expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({
55 kind: "video",
56 source: {
57 srcFragment: "#t=2",
58 resource: { path: "/tmp/clip#one.mp4" },
59 actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } },
60 },
61 });
62 });
63
64 it("resolves protocol-relative media for native APIs without rewriting its signed query", () => {
65 expect(
66 resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input),
67 ).toMatchObject({
68 kind: "video",
69 source: {
70 uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2",
71 actionsSource: {
72 reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" },
73 },
74 },
75 });
76 });
77});
apps/mobile/src/lib/markdownMedia.ts created+89
......@@ -0,0 +1,89 @@
1import {
2 classifyMarkdownImageSource,
3 markdownImageSourceFragment,
4} from "@t3tools/client-runtime/markdown-images";
5import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
6import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
7import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
8import {
9 mediaFileReference,
10 mediaReferenceFileName,
11 mediaUrlReference,
12} from "@t3tools/client-runtime/media-reference";
13
14import type { FilePreviewSource } from "../components/FilePreviewModal";
15import type { MediaVideoPreviewSource } from "./videoPreviewSource";
16import type { MediaActionsSource } from "./mediaActions";
17
18/** Resolves only explicit media references. Ordinary links keep their existing navigation. */
19export function resolveMarkdownMediaPreview(
20 href: string,
21 input: {
22 readonly environmentId: EnvironmentId;
23 readonly threadId: ThreadId;
24 readonly workspaceRoot: string | null | undefined;
25 /** Image syntax can target an endpoint without a recognizable extension. */
26 readonly imageEmbed?: boolean;
27 },
28):
29 | { readonly kind: "image"; readonly source: FilePreviewSource }
30 | { readonly kind: "video"; readonly source: MediaVideoPreviewSource }
31 | null {
32 const classified = classifyMarkdownImageSource(href, input.workspaceRoot);
33 if (classified._tag === "Blocked") return null;
34 const path =
35 classified._tag === "WorkspaceFile"
36 ? classified.path.replace(/:\d+(?::\d+)?$/, "")
37 : classified.uri.split(/[?#]/, 1)[0]!;
38 const basename = path.split(/[\\/]/).at(-1) ?? "";
39 const extensionIndex = basename.lastIndexOf(".");
40 // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again.
41 const detectedMimeType =
42 classified._tag === "Direct"
43 ? mediaMimeType(classified.uri)
44 : extensionIndex < 0
45 ? null
46 : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
47 const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null);
48 if (mimeType === null) return null;
49 const kind = mimeType.startsWith("video/") ? "video" : "image";
50 const reference =
51 classified._tag === "Direct"
52 ? mediaUrlReference(classified.uri)
53 : mediaFileReference(path, input.workspaceRoot);
54 const name =
55 (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image");
56 const srcFragment = markdownImageSourceFragment(href);
57 const target =
58 classified._tag === "Direct"
59 ? { uri: normalizeNativeMarkdownUrl(classified.uri) }
60 : {
61 environmentId: input.environmentId,
62 resource: {
63 _tag: "media-file" as const,
64 threadId: input.threadId,
65 path,
66 },
67 ...(srcFragment ? { srcFragment } : {}),
68 };
69 const actionsSource: MediaActionsSource =
70 classified._tag === "Direct"
71 ? { reference, uri: classified.uri, name, mimeType }
72 : {
73 reference,
74 environmentId: input.environmentId,
75 threadId: input.threadId,
76 resource: { _tag: "media-file", threadId: input.threadId, path },
77 name,
78 mimeType,
79 };
80 return kind === "video"
81 ? {
82 kind,
83 source: { type: "media", name, mimeType, ...target, actionsSource },
84 }
85 : {
86 kind,
87 source: { kind, name, ...target, actionsSource },
88 };
89}
apps/mobile/src/lib/mediaActions.ts created+121
......@@ -0,0 +1,121 @@
1import { useNavigation } from "@react-navigation/native";
2import type { MediaReference } from "@t3tools/client-runtime/media-reference";
3import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
5import { useEffect, useRef, useState } from "react";
6import { Alert } from "react-native";
7
8import { useRefreshAssetUrl } from "../state/assets";
9import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
10import { copyTextWithHaptic } from "./copyTextWithHaptic";
11
12/** Authored source metadata is kept separate from temporary preview/download URLs. */
13export type MediaActionsSource = {
14 readonly reference?: MediaReference;
15 readonly name: string;
16 readonly mimeType: string;
17} & (
18 | { readonly uri: string }
19 | {
20 readonly environmentId: EnvironmentId;
21 readonly threadId: ThreadId;
22 readonly resource: AssetResource;
23 }
24);
25
26export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) {
27 const navigation = useNavigation();
28 const refresh = useRefreshAssetUrl(
29 source && "environmentId" in source ? source.environmentId : null,
30 source && "resource" in source ? source.resource : null,
31 );
32 const controller = useRef<AbortController | null>(null);
33 const [sharing, setSharing] = useState(false);
34 useEffect(() => () => controller.current?.abort(), []);
35
36 const share = () => {
37 if (!source || controller.current) return;
38 const request = new AbortController();
39 controller.current = request;
40 setSharing(true);
41 void (async () => {
42 const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh();
43 if (request.signal.aborted) return;
44 if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again.");
45 const input = {
46 attachment: { name: source.name, mimeType: source.mimeType },
47 signal: request.signal,
48 };
49 if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri });
50 else await downloadAndShareAttachment({ ...input, url: uri });
51 })()
52 .catch((error: unknown) => {
53 if (!request.signal.aborted) {
54 Alert.alert(
55 "Could not share file",
56 error instanceof Error ? error.message : "Try again.",
57 );
58 }
59 })
60 .finally(() => {
61 if (controller.current === request) {
62 controller.current = null;
63 if (!request.signal.aborted) setSharing(false);
64 }
65 });
66 };
67
68 const reference = source?.reference;
69 const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source
70 ? [
71 ...(reference?.kind === "file"
72 ? [
73 {
74 id: "copy-path",
75 title: "Copy full path",
76 run: () => copyTextWithHaptic(reference.path),
77 },
78 ...(reference.relativePath
79 ? [
80 {
81 id: "copy-relative-path",
82 title: "Copy relative path",
83 run: () => copyTextWithHaptic(reference.relativePath!),
84 },
85 ]
86 : []),
87 ...(reference.relativePath && source && "environmentId" in source
88 ? [
89 {
90 id: "open-file",
91 title: "Open in file viewer",
92 run: () => {
93 onOpenFile?.();
94 navigation.navigate("ThreadFile", {
95 environmentId: String(source.environmentId),
96 threadId: String(source.threadId),
97 path: reference.relativePath!.split("/"),
98 });
99 },
100 },
101 ]
102 : []),
103 ]
104 : reference
105 ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }]
106 : []),
107 {
108 id: "share",
109 title: sharing ? "Opening share sheet…" : "Save or share",
110 run: share,
111 disabled: sharing,
112 },
113 ]
114 : [];
115 return {
116 title: reference?.kind === "file" ? reference.path : reference?.url,
117 actions,
118 sharing,
119 share,
120 };
121}
apps/mobile/src/lib/nativeMarkdownText.test.ts+37
......@@ -11,6 +11,43 @@ import {
1111} from "@t3tools/mobile-markdown-text/markdown";
1212
1313describe("nativeMarkdownTextRuns", () => {
14 it("links a path-shaped code span without changing the same path in prose", () => {
15 expect(
16 nativeMarkdownTextRuns({
17 type: "paragraph",
18 children: [
19 { type: "text", content: "/tmp/frame.png " },
20 { type: "code_inline", content: "/tmp/frame.png" },
21 ],
22 }),
23 ).toEqual([
24 { text: "/tmp/frame.png " },
25 { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" },
26 ]);
27 });
28
29 it("preserves the destination of a link with a code-formatted label", () => {
30 expect(
31 nativeMarkdownTextRuns({
32 type: "paragraph",
33 children: [
34 {
35 type: "link",
36 href: "https://example.com/docs",
37 children: [{ type: "code_inline", content: "src/main.ts" }],
38 },
39 ],
40 }),
41 ).toEqual([
42 {
43 text: "src/main.ts",
44 code: true,
45 href: "https://example.com/docs",
46 externalHost: "example.com",
47 },
48 ]);
49 });
50
1451 it("preserves inline emphasis and code styles", () => {
1552 const node: MarkdownNode = {
1653 type: "paragraph",
apps/mobile/src/lib/videoPreviewSource.ts created+54
......@@ -0,0 +1,54 @@
1import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts";
2
3import type { DraftComposerFileAttachment } from "./composerImages";
4import type { MediaActionsSource } from "./mediaActions";
5
6export type MediaVideoPreviewSource = {
7 readonly type: "media";
8 readonly name: string;
9 readonly mimeType: string;
10 readonly sourceIdentifier?: string;
11 readonly srcFragment?: string;
12 readonly actionsSource?: MediaActionsSource;
13} & (
14 | { readonly uri: string }
15 | {
16 readonly environmentId: EnvironmentId;
17 readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
18 }
19);
20
21/** Resolves the current capability without making it the identity of the video. */
22export function mediaVideoPreviewUri(
23 source: MediaVideoPreviewSource,
24 assetUrl: string | null,
25): string | null {
26 if ("uri" in source) return source.uri;
27 return assetUrl === null ? null : assetUrl + (source.srcFragment ?? "");
28}
29
30/** Keeps thumbnails independent of refreshed asset signatures and scoped to their environment. */
31export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string {
32 return JSON.stringify(
33 "uri" in source
34 ? ["media-video", source.uri]
35 : [
36 "media-video",
37 source.environmentId,
38 source.resource.threadId,
39 source.resource.path,
40 source.srcFragment ?? "",
41 ],
42 );
43}
44
45export type AttachmentVideoPreviewSource = (
46 | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment }
47 | {
48 readonly type: "remote";
49 readonly environmentId: EnvironmentId;
50 readonly attachment: ChatFileAttachment;
51 }
52) & { readonly sourceIdentifier?: string };
53
54export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource;
apps/mobile/src/state/assets.ts+22
......@@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react";
22import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
33import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
44import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useCallback } from "react";
56
67import { connectionAtomRuntime } from "../connection/runtime";
78import { usePreparedConnection } from "./session";
9import { useAtomQueryRunner } from "./use-atom-query-runner";
810
911export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
1012
......@@ -44,3 +46,23 @@ export function useAssetUrl(
4446 const state = useAssetUrlState(environmentId, resource);
4547 return state._tag === "Success" ? state.url : null;
4648}
49
50/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */
51export function useRefreshAssetUrl(
52 environmentId: EnvironmentId | null,
53 resource: AssetResource | null,
54): () => Promise<string | null> {
55 const connection = usePreparedConnection(environmentId);
56 const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
57 const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
58 refresh: true,
59 reportFailure: false,
60 });
61 return useCallback(async () => {
62 if (environmentId === null || resource === null || httpBaseUrl === null) return null;
63 const result = await createUrl({ environmentId, input: { resource } });
64 return result._tag === "Success"
65 ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl)
66 : null;
67 }, [createUrl, environmentId, httpBaseUrl, resource]);
68}
apps/mobile/src/state/use-atom-query-runner.ts+5-3
......@@ -1,7 +1,7 @@
11import { RegistryContext } from "@effect/atom-react";
22import {
33 executeAtomQuery,
4 type AtomCommandOptions,
4 type AtomQueryOptions,
55 type AtomCommandResult,
66} from "@t3tools/client-runtime/state/runtime";
77import { AsyncResult, type Atom } from "effect/unstable/reactivity";
......@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";
99
1010export function useAtomQueryRunner<T, A, E>(
1111 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,
12 options?: string | AtomCommandOptions,
12 options?: string | AtomQueryOptions,
1313): (target: T) => Promise<AtomCommandResult<A, E>> {
1414 const registry = useContext(RegistryContext);
1515 const explicitLabel = typeof options === "string" ? options : options?.label;
1616 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);
1717 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);
18 const refresh = typeof options === "string" ? false : (options?.refresh ?? false);
1819
1920 return useCallback(
2021 (target: T) => {
......@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(
2324 label: explicitLabel ?? atom.label?.[0] ?? "atom query",
2425 reportFailure,
2526 reportDefect,
27 refresh,
2628 });
2729 },
28 [explicitLabel, family, registry, reportDefect, reportFailure],
30 [explicitLabel, family, registry, refresh, reportDefect, reportFailure],
2931 );
3032}
apps/server/src/assets/AssetAccess.test.ts+335
......@@ -1,4 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary.
12import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeFSP from "node:fs/promises";
25import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
36import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
47import { describe, expect, it } from "@effect/vitest";
......@@ -9,18 +12,28 @@ import * as Layer from "effect/Layer";
912import * as Path from "effect/Path";
1013import * as PlatformError from "effect/PlatformError";
1114import * as TestClock from "effect/testing/TestClock";
15import { HttpServerResponse } from "effect/unstable/http";
16import { vi } from "vite-plus/test";
1217
1318import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1419import * as ServerConfig from "../config.ts";
1520import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
1621import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
1722import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import { assetFileResponse } from "../http.ts";
1824import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
25import { openMediaFile } from "./MediaFile.ts";
26
27vi.mock("node:fs/promises", async (importOriginal) => {
28 const actual = await importOriginal<typeof NodeFSP>();
29 return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) };
30});
1931
2032const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2133 prefix: "t3-asset-access-test-",
2234});
2335const testLayer = Layer.mergeAll(
36 NodeHttpPlatform.layer,
2437 configLayer,
2538 WorkspacePaths.layer,
2639 ProjectFaviconResolver.layer.pipe(
......@@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll(
3144).pipe(Layer.provideMerge(NodeServices.layer));
3245
3346describe("AssetAccess", () => {
47 it.effect("issues exact URLs for images and videos outside the workspace", () =>
48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" });
52 const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" });
53 for (const [name, mimeType] of [
54 ["screenshot.png", "image/png"],
55 ["recording.mp4", "video/mp4"],
56 ["recording.webm", "video/webm"],
57 ] as const) {
58 const filePath = path.join(outside, name);
59 yield* fs.writeFileString(filePath, "media");
60 const canonicalFile = yield* fs.realPath(filePath);
61 const result = yield* issueAssetUrl({
62 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
63 workspaceRoot: root,
64 });
65 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
66 const separator = suffix.indexOf("/");
67 const token = suffix.slice(0, separator);
68 expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({
69 kind: "file",
70 path: canonicalFile,
71 mimeType,
72 });
73 yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling");
74 expect(yield* resolveAsset(token, "sibling.png")).toBeNull();
75 expect(yield* resolveAsset(token, `../${name}`)).toBeNull();
76 expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull();
77 }
78 }).pipe(Effect.provide(testLayer)),
79 );
80
81 it.effect("resolves relative media paths from the thread workspace, including outside it", () =>
82 Effect.gen(function* () {
83 const fs = yield* FileSystem.FileSystem;
84 const path = yield* Path.Path;
85 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" });
86 const root = path.join(directory, "workspace");
87 yield* fs.makeDirectory(root);
88 for (const relativePath of ["screenshot.png", "../recording.mp4"]) {
89 const filePath = path.resolve(root, relativePath);
90 yield* fs.writeFileString(filePath, "media");
91 const result = yield* issueAssetUrl({
92 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath },
93 workspaceRoot: root,
94 });
95 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
96 const separator = suffix.indexOf("/");
97 expect(
98 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)),
99 ).toMatchObject({
100 kind: "file",
101 path: yield* fs.realPath(filePath),
102 });
103 }
104 }).pipe(Effect.provide(testLayer)),
105 );
106
107 it.effect("rejects non-media files, disguised targets, and directories", () =>
108 Effect.gen(function* () {
109 const fs = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
111 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
112 for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
113 const filePath = path.join(root, name);
114 yield* fs.writeFileString(filePath, "not media");
115 const error = yield* issueAssetUrl({
116 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
117 }).pipe(Effect.flip);
118 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
119 }
120 const disguisedPath = path.join(root, "disguised.png");
121 yield* fs.symlink(path.join(root, "report.html"), disguisedPath);
122 const disguisedError = yield* issueAssetUrl({
123 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },
124 }).pipe(Effect.flip);
125 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);
126 const directoryPath = path.join(root, "directory.png");
127 yield* fs.makeDirectory(directoryPath);
128 const directoryError = yield* issueAssetUrl({
129 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath },
130 }).pipe(Effect.flip);
131 expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError");
132 }).pipe(Effect.provide(testLayer)),
133 );
134
135 it.effect("binds media URLs to the canonical target and rejects symlink substitution", () =>
136 Effect.gen(function* () {
137 const fs = yield* FileSystem.FileSystem;
138 const path = yield* Path.Path;
139 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" });
140 const filePath = path.join(root, "actual.svg");
141 const aliasPath = path.join(root, "alias.png");
142 const replacementPath = path.join(root, "other.svg");
143 yield* fs.writeFileString(filePath, "<svg/>");
144 yield* fs.writeFileString(replacementPath, "<svg>private</svg>");
145 yield* fs.symlink(filePath, aliasPath);
146 const canonicalFile = yield* fs.realPath(filePath);
147 const result = yield* issueAssetUrl({
148 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath },
149 });
150 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
151 const separator = suffix.indexOf("/");
152 const token = suffix.slice(0, separator);
153 const name = suffix.slice(separator + 1);
154 const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
155 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
156 yield* fs.remove(aliasPath);
157 yield* fs.symlink(replacementPath, aliasPath);
158 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
159 yield* fs.remove(filePath);
160 yield* fs.symlink(replacementPath, filePath);
161 expect(yield* resolveAsset(token, name)).toBeNull();
162 }).pipe(Effect.provide(testLayer)),
163 );
164
165 it.effect("keeps full and partial responses bound to the file opened during resolution", () =>
166 Effect.gen(function* () {
167 const fs = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });
170 const filePath = path.join(root, "recording.mp4");
171 const savedPath = path.join(root, "saved.mp4");
172 const secretPath = path.join(root, "secret.txt");
173 yield* fs.writeFileString(filePath, "0123456789");
174 yield* fs.writeFileString(secretPath, "private information");
175 const result = yield* issueAssetUrl({
176 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
177 });
178 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
179 const separator = suffix.indexOf("/");
180 for (const [range, expected, status] of [
181 [undefined, "0123456789", 200],
182 ["bytes=2-5", "2345", 206],
183 ] as const) {
184 const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
185 if (!asset) throw new Error("Expected a resolved media file");
186
187 yield* fs.rename(filePath, savedPath);
188 yield* fs.symlink(secretPath, filePath);
189 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));
190 expect(response.status).toBe(status);
191 expect(response.headers.get("content-length")).toBe(String(expected.length));
192 expect(yield* Effect.promise(() => response.text())).toBe(expected);
193 yield* fs.remove(filePath);
194 yield* fs.rename(savedPath, filePath);
195 }
196 }).pipe(Effect.provide(testLayer)),
197 );
198
199 it.effect("rejects a symlink swapped in after canonical validation but before open", () =>
200 Effect.gen(function* () {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });
204 const filePath = path.join(root, "recording.mp4");
205 const secretPath = path.join(root, "secret.txt");
206 yield* fs.writeFileString(filePath, "video");
207 yield* fs.writeFileString(secretPath, "secret");
208 const canonicalPath = yield* fs.realPath(filePath);
209 const result = yield* issueAssetUrl({
210 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
211 });
212 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
213 const separator = suffix.indexOf("/");
214 const swappingFileSystem = FileSystem.FileSystem.of({
215 ...fs,
216 stat: Effect.fn(function* (requestedPath) {
217 const info = yield* fs.stat(requestedPath);
218 if (requestedPath === canonicalPath) {
219 yield* fs.remove(filePath);
220 yield* fs.symlink(secretPath, filePath);
221 }
222 return info;
223 }),
224 });
225 expect(
226 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
227 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
228 ),
229 ).toBeNull();
230 }).pipe(Effect.provide(testLayer)),
231 );
232
233 it.effect("closes a descriptor rejected when its path changes during open", () =>
234 Effect.gen(function* () {
235 const fs = yield* FileSystem.FileSystem;
236 const path = yield* Path.Path;
237 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });
238 const filePath = path.join(root, "recording.mp4");
239 const secretPath = path.join(root, "secret.txt");
240 yield* fs.writeFileString(filePath, "video");
241 yield* fs.writeFileString(secretPath, "secret");
242 const canonicalPath = yield* fs.realPath(filePath);
243 const originalOpen = (yield* Effect.promise(() =>
244 vi.importActual<typeof NodeFSP>("node:fs/promises"),
245 )).open;
246 let opened: NodeFSP.FileHandle | undefined;
247 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {
248 const handle = await originalOpen(target, flags, mode);
249 if (target === canonicalPath) {
250 opened = handle;
251 await NodeFSP.unlink(filePath);
252 await NodeFSP.symlink(secretPath, filePath);
253 }
254 return handle;
255 });
256 yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen)));
257 expect(yield* openMediaFile(canonicalPath)).toBeNull();
258 expect(opened).toBeDefined();
259 expect(opened?.fd).toBe(-1);
260 }).pipe(Effect.provide(testLayer)),
261 );
262
263 it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () =>
264 Effect.gen(function* () {
265 const fs = yield* FileSystem.FileSystem;
266 const path = yield* Path.Path;
267 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" });
268 const publicDirectory = path.join(root, "public");
269 const privateDirectory = path.join(root, "private");
270 yield* fs.makeDirectory(publicDirectory);
271 yield* fs.makeDirectory(privateDirectory);
272 const filePath = path.join(publicDirectory, "recording.mp4");
273 yield* fs.writeFileString(filePath, "public video");
274 yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video");
275 const canonicalPath = yield* fs.realPath(filePath);
276 const result = yield* issueAssetUrl({
277 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
278 });
279 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
280 const separator = suffix.indexOf("/");
281 const native = yield* Effect.promise(() =>
282 vi.importActual<typeof NodeFSP>("node:fs/promises"),
283 );
284 const savedDirectory = path.join(root, "saved");
285 const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => {
286 // A pathname-only guard can see the original parents during realpath,
287 // but the private file during both lstat calls and open.
288 await native.unlink(publicDirectory);
289 await native.rename(savedDirectory, publicDirectory);
290 const canonical = await native.realpath(canonicalPath);
291 await native.rename(publicDirectory, savedDirectory);
292 await native.symlink(privateDirectory, publicDirectory, "junction");
293 return canonical;
294 });
295 yield* Effect.addFinalizer(() =>
296 Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)),
297 );
298 const swappingFileSystem = FileSystem.FileSystem.of({
299 ...fs,
300 realPath: Effect.fn(function* (requestedPath) {
301 const canonical = yield* fs.realPath(requestedPath);
302 if (requestedPath === canonicalPath) {
303 yield* fs.rename(publicDirectory, savedDirectory);
304 yield* Effect.promise(() =>
305 NodeFSP.symlink(privateDirectory, publicDirectory, "junction"),
306 );
307 }
308 return canonical;
309 }),
310 });
311 expect(
312 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
313 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
314 ),
315 ).toBeNull();
316 }).pipe(Effect.provide(testLayer)),
317 );
318
319 it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () =>
320 Effect.gen(function* () {
321 const fs = yield* FileSystem.FileSystem;
322 const path = yield* Path.Path;
323 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" });
324 const filePath = path.join(root, "recording.mp4");
325 yield* fs.writeFileString(filePath, "original");
326 const input = {
327 resource: {
328 _tag: "media-file" as const,
329 threadId: ThreadId.make("thread-1"),
330 path: filePath,
331 },
332 };
333 const original = yield* issueAssetUrl(input);
334 const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
335 const separator = suffix.indexOf("/");
336 const token = suffix.slice(0, separator);
337 const name = suffix.slice(separator + 1);
338 yield* fs.writeFileString(filePath, "in-place edit");
339 const edited = yield* resolveAsset(token, name);
340 if (!edited) throw new Error("Expected the edited media file");
341 const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited));
342 expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit");
343
344 const replacement = path.join(root, "replacement.mp4");
345 yield* fs.writeFileString(replacement, "replacement");
346 yield* fs.rename(replacement, filePath);
347 expect(yield* resolveAsset(token, name)).toBeNull();
348
349 const renewed = yield* issueAssetUrl(input);
350 const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
351 const renewedSeparator = renewedSuffix.indexOf("/");
352 const renewedAsset = yield* resolveAsset(
353 renewedSuffix.slice(0, renewedSeparator),
354 renewedSuffix.slice(renewedSeparator + 1),
355 );
356 if (!renewedAsset) throw new Error("Expected the replacement media file");
357 const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset));
358 expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement");
359 yield* fs.remove(filePath);
360 expect(
361 yield* resolveAsset(
362 renewedSuffix.slice(0, renewedSeparator),
363 renewedSuffix.slice(renewedSeparator + 1),
364 ),
365 ).toBeNull();
366 }).pipe(Effect.provide(testLayer)),
367 );
368
34369 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
35370 Effect.gen(function* () {
36371 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+84
......@@ -16,6 +16,7 @@ import {
1616import {
1717 isWorkspaceImagePreviewPath,
1818 isWorkspacePreviewEntryPath,
19 mediaMimeTypeFromExtension,
1920 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
2021 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
2122} from "@t3tools/shared/filePreview";
......@@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta
4142import * as ServerConfig from "../config.ts";
4243import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4344import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
45import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
4446
4547export const ASSET_ROUTE_PREFIX = "/api/assets";
4648
......@@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([
7678 relativePath: Schema.String,
7779 expiresAt: Schema.Number,
7880 }),
81 Schema.Struct({
82 version: Schema.Literal(1),
83 kind: Schema.Literal("media-file-exact"),
84 filePath: Schema.String,
85 device: Schema.String,
86 inode: Schema.String,
87 expiresAt: Schema.Number,
88 }),
7989 Schema.Struct({
8090 version: Schema.Literal(1),
8191 kind: Schema.Literal("attachment"),
......@@ -115,6 +125,7 @@ export type ResolvedAsset = {
115125 readonly download?: boolean;
116126 readonly fileName?: string;
117127 readonly mimeType?: string;
128 readonly file?: OpenMediaFile;
118129};
119130
120131function decodeClaims(encodedPayload: string): AssetClaims | null {
......@@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
211222 let sourcePath: string | undefined;
212223
213224 switch (input.resource._tag) {
225 case "media-file": {
226 let requestedPath = input.resource.path;
227 if (!path.isAbsolute(requestedPath)) {
228 if (!input.workspaceRoot) {
229 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });
230 }
231 const workspaceRoot = yield* workspacePaths
232 .normalizeWorkspaceRoot(input.workspaceRoot)
233 .pipe(
234 Effect.mapError(
235 (cause) =>
236 new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }),
237 ),
238 );
239 requestedPath = path.resolve(workspaceRoot, requestedPath);
240 }
241 const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe(
242 Effect.mapError(
243 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
244 ),
245 );
246 if (!canonicalFile) {
247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
248 }
249 if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
250 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
251 }
252 const identity = yield* openMediaFile(canonicalFile).pipe(
253 Effect.map((file) =>
254 file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null,
255 ),
256 Effect.scoped,
257 Effect.mapError(
258 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
259 ),
260 );
261 if (!identity) {
262 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
263 }
264 claims = {
265 version: 1,
266 kind: "media-file-exact",
267 filePath: canonicalFile,
268 ...identity,
269 expiresAt,
270 };
271 fileName = path.basename(canonicalFile);
272 break;
273 }
214274 case "workspace-file": {
215275 if (!input.workspaceRoot) {
216276 return yield* new AssetWorkspaceContextNotFoundError({
......@@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
526586 const decodedPath = decodeRelativePath(relativePath);
527587 if (decodedPath === null) return null;
528588 const path = yield* Path.Path;
589 if (claims.kind === "media-file-exact") {
590 if (decodedPath !== path.basename(claims.filePath)) return null;
591 const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe(
592 Effect.tapError((cause) =>
593 Effect.logError("Failed to resolve canonical media path.", {
594 filePath: claims.filePath,
595 cause,
596 }),
597 ),
598 Effect.orElseSucceed(() => null),
599 );
600 if (canonicalFile !== claims.filePath) return null;
601 const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile));
602 if (!mimeType) return null;
603 const file = yield* openMediaFile(canonicalFile, claims).pipe(
604 Effect.tapError((cause) =>
605 Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }),
606 ),
607 Effect.orElseSucceed(() => null),
608 );
609 return file
610 ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset)
611 : null;
612 }
529613 if (claims.kind === "workspace-file-exact") {
530614 if (decodedPath !== path.basename(claims.relativePath)) return null;
531615 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
apps/server/src/assets/MediaFile.ts created+113
......@@ -0,0 +1,113 @@
1// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow
2// or non-blocking open flags, and the response must keep the validated descriptor.
3import * as NodeFS from "node:fs";
4import * as NodeFSP from "node:fs/promises";
5
6import * as NodeStream from "@effect/platform-node/NodeStream";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9
10class MediaFileOpenError extends Schema.TaggedErrorClass<MediaFileOpenError>()(
11 "MediaFileOpenError",
12 {
13 path: Schema.String,
14 cause: Schema.Defect(),
15 },
16) {
17 override get message(): string {
18 return `Failed to open media file '${this.path}'.`;
19 }
20}
21
22class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()(
23 "MediaFileStatError",
24 {
25 path: Schema.String,
26 cause: Schema.Defect(),
27 },
28) {
29 override get message(): string {
30 return `Failed to read metadata for media file '${this.path}'.`;
31 }
32}
33
34/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */
35export interface OpenMediaFile {
36 readonly handle: NodeFSP.FileHandle;
37 readonly info: NodeFS.BigIntStats;
38}
39
40/** Opens a canonical media path once. Replacements cannot change the response's source. */
41export const openMediaFile = Effect.fn("openMediaFile")(function* (
42 filePath: string,
43 identity?: { readonly device: string; readonly inode: string },
44) {
45 return yield* Effect.acquireRelease(
46 Effect.tryPromise({
47 try: async () => {
48 const before = await NodeFSP.lstat(filePath, { bigint: true });
49 if (!before.isFile() || before.ino === 0n) return null;
50 if (
51 identity &&
52 (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode)
53 ) {
54 return null;
55 }
56
57 // Windows lacks these flags; the descriptor/path identity checks still apply.
58 const handle = await NodeFSP.open(
59 filePath,
60 NodeFS.constants.O_RDONLY |
61 (NodeFS.constants.O_NOFOLLOW ?? 0) |
62 (NodeFS.constants.O_NONBLOCK ?? 0),
63 );
64 let accepted = false;
65 try {
66 const info = await handle.stat({ bigint: true });
67 if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null;
68 if (
69 identity &&
70 (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode)
71 ) {
72 return null;
73 }
74 if ((await NodeFSP.realpath(filePath)) !== filePath) return null;
75 const after = await NodeFSP.lstat(filePath, { bigint: true });
76 if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null;
77 accepted = true;
78 return { handle, info } satisfies OpenMediaFile;
79 } finally {
80 if (!accepted) await handle.close();
81 }
82 },
83 catch: (cause) => new MediaFileOpenError({ path: filePath, cause }),
84 }),
85 (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void),
86 );
87});
88
89export const statMediaFile = Effect.fn("statMediaFile")(function* (
90 filePath: string,
91 file: OpenMediaFile,
92) {
93 return yield* Effect.tryPromise({
94 try: () => file.handle.stat({ bigint: true }),
95 catch: (cause) => new MediaFileStatError({ path: filePath, cause }),
96 });
97});
98
99export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => {
100 const start = Number(offset);
101 const end = Number(offset + bytesToRead - 1n);
102 if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
103 return null;
104 }
105 return NodeStream.fromReadable<Uint8Array>({
106 evaluate: () =>
107 file.handle.createReadStream({
108 autoClose: false,
109 start,
110 end,
111 }),
112 });
113};
apps/server/src/http.test.ts+175-1
......@@ -1,5 +1,5 @@
11import { expect, it } from "@effect/vitest";
2import { describe } from "vite-plus/test";
2import { describe, vi } from "vite-plus/test";
33import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import * as Effect from "effect/Effect";
......@@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem";
77import * as Layer from "effect/Layer";
88import * as Path from "effect/Path";
99import { HttpServerResponse } from "effect/unstable/http";
10import { openMediaFile } from "./assets/MediaFile.ts";
1011
1112import {
1213 assetResponseHeaders,
......@@ -19,6 +20,179 @@ import {
1920const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
2021
2122describe("video asset byte ranges", () => {
23 it.effect("uses current descriptor metadata after an in-place truncate or extension", () =>
24 Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" });
28 const filePath = path.join(directory, "clip.mp4");
29 for (const [contents, range, method, expected, status, contentRange] of [
30 ["1234", undefined, "GET", "1234", 200, null],
31 ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null],
32 ["1234", "bytes=4-", "GET", "", 416, "bytes */4"],
33 ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"],
34 ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"],
35 ["0123456789abcdef", undefined, "HEAD", "", 200, null],
36 ["", undefined, "GET", "", 200, null],
37 ["", "bytes=0-1", "GET", "", 416, "bytes */0"],
38 ] as const) {
39 yield* fs.writeFileString(filePath, "0123456789");
40 const canonicalPath = yield* fs.realPath(filePath);
41 const file = yield* openMediaFile(canonicalPath);
42 if (!file) throw new Error("Expected an opened media file");
43 yield* fs.writeFileString(filePath, contents);
44 const response = HttpServerResponse.toWeb(
45 yield* assetFileResponse(
46 { path: canonicalPath, file, mimeType: "video/mp4" },
47 range,
48 undefined,
49 method,
50 ),
51 );
52 expect(response.status).toBe(status);
53 expect(response.headers.get("content-range")).toBe(contentRange);
54 if (status !== 416) {
55 expect(response.headers.get("content-length")).toBe(
56 String(method === "HEAD" ? contents.length : expected.length),
57 );
58 }
59 expect(yield* Effect.promise(() => response.text())).toBe(expected);
60 }
61 }).pipe(Effect.provide(fileResponseLayer)),
62 );
63
64 it.effect(
65 "rejects unaddressable ranges before streaming and preserves small ranges on large files",
66 () =>
67 Effect.gen(function* () {
68 const fs = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" });
71 const filePath = path.join(directory, "clip.mp4");
72 yield* fs.writeFileString(filePath, "0123456789");
73 const canonicalPath = yield* fs.realPath(filePath);
74 const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
75 const size = unsafeOffset + 32n;
76 for (const [range, status] of [
77 [`bytes=${unsafeOffset}-${unsafeOffset}`, 416],
78 [`bytes=0-${unsafeOffset}`, 416],
79 ["bytes=-1", 416],
80 [undefined, 413],
81 ["bytes=0-1", 206],
82 ] as const) {
83 const file = yield* openMediaFile(canonicalPath);
84 if (!file) throw new Error("Expected an opened media file");
85 // Model a sparse file beyond the native stream's numeric addressing limit.
86 const info = yield* Effect.promise(() => file.handle.stat({ bigint: true }));
87 info.size = size;
88 const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info);
89 yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore()));
90 const response = HttpServerResponse.toWeb(
91 yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range),
92 );
93 expect(response.status).toBe(status);
94 if (status === 416) {
95 expect(response.headers.get("content-range")).toBe(`bytes */${size}`);
96 expect(yield* Effect.promise(() => response.text())).toBe("");
97 } else if (status === 206) {
98 expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`);
99 expect(yield* Effect.promise(() => response.text())).toBe("01");
100 } else {
101 expect(yield* Effect.promise(() => response.text())).toBe(
102 "File is too large to preview.",
103 );
104 }
105 }
106 }).pipe(Effect.provide(fileResponseLayer)),
107 );
108
109 it.effect("streams guarded file ranges, including suffixes and conditional requests", () =>
110 Effect.gen(function* () {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" });
114 const filePath = path.join(directory, "clip.mp4");
115 yield* fs.writeFileString(filePath, "0123456789");
116 const canonicalPath = yield* fs.realPath(filePath);
117 for (const [range, ifRange, expected, status, contentRange] of [
118 [undefined, undefined, "0123456789", 200, null],
119 ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"],
120 ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"],
121 ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"],
122 ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"],
123 ["bytes=10-", undefined, "", 416, "bytes */10"],
124 ["bytes=0-1", '"old-etag"', "0123456789", 200, null],
125 ["bytes=0-1", "", "0123456789", 200, null],
126 ] as const) {
127 const file = yield* openMediaFile(canonicalPath);
128 if (!file) throw new Error("Expected an opened media file");
129 const response = HttpServerResponse.toWeb(
130 yield* assetFileResponse(
131 { path: canonicalPath, file, mimeType: "video/mp4" },
132 range,
133 ifRange,
134 ),
135 );
136 expect(response.status).toBe(status);
137 expect(response.headers.get("accept-ranges")).toBe("bytes");
138 expect(response.headers.get("content-range")).toBe(contentRange);
139 expect(response.headers.get("cache-control")).toBe("private, no-store");
140 expect(response.headers.get("etag")).toBeNull();
141 expect(response.headers.get("last-modified")).toBeNull();
142 if (status !== 416)
143 expect(response.headers.get("content-length")).toBe(String(expected.length));
144 expect(yield* Effect.promise(() => response.text())).toBe(expected);
145 }
146 }).pipe(Effect.provide(fileResponseLayer)),
147 );
148
149 it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () =>
150 Effect.gen(function* () {
151 const fs = yield* FileSystem.FileSystem;
152 const path = yield* Path.Path;
153 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" });
154 const filePath = path.join(directory, "clip.mp4");
155 const bytes = new Uint8Array(1024 * 1024).fill(42);
156 yield* fs.writeFile(filePath, bytes);
157 const canonicalPath = yield* fs.realPath(filePath);
158 for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) {
159 const file = yield* Effect.scoped(
160 Effect.gen(function* () {
161 const file = yield* openMediaFile(canonicalPath);
162 if (!file) throw new Error("Expected an opened media file");
163 const response = HttpServerResponse.toWeb(
164 yield* assetFileResponse(
165 { path: canonicalPath, file, mimeType: "video/mp4" },
166 mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-",
167 undefined,
168 mode === "HEAD" ? "HEAD" : "GET",
169 ),
170 );
171 if (mode === "HEAD") {
172 expect(response.status).toBe(200);
173 expect(response.headers.get("content-length")).toBe(String(bytes.length));
174 expect(response.headers.get("content-range")).toBeNull();
175 expect(yield* Effect.promise(() => response.text())).toBe("");
176 } else if (mode === "rejected") {
177 expect(response.status).toBe(416);
178 expect(yield* Effect.promise(() => response.text())).toBe("");
179 } else if (mode === "cancelled") {
180 const reader = response.body!.getReader();
181 const first = yield* Effect.promise(() => reader.read());
182 expect(first.done).toBe(false);
183 expect(first.value!.byteLength).toBeLessThan(bytes.length);
184 yield* Effect.promise(() => reader.cancel());
185 } else {
186 expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer);
187 }
188 return file;
189 }),
190 );
191 expect(file.handle.fd).toBe(-1);
192 }
193 }).pipe(Effect.provide(fileResponseLayer)),
194 );
195
22196 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
23197 Effect.gen(function* () {
24198 const fs = yield* FileSystem.FileSystem;
apps/server/src/http.ts+46-10
......@@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability";
2929
3030import * as ServerConfig from "./config.ts";
3131import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
32import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
3233import {
3334 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
3435 storeAttachmentUpload,
......@@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) {
124125 }
125126 const start = first ?? (last! >= size ? 0n : size - last!);
126127 const end = first === null || last === null || last >= size ? size - 1n : last;
128 if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) {
129 return { _tag: "Unsatisfiable" as const };
130 }
127131 return {
128132 _tag: "Range" as const,
129133 offset: start,
......@@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
138142 readonly download?: boolean;
139143 readonly fileName?: string;
140144 readonly mimeType?: string;
145 readonly file?: OpenMediaFile;
141146 },
142147 rangeHeader?: string,
143148 ifRangeHeader?: string,
149 method: "GET" | "HEAD" = "GET",
144150) {
145151 const headers = assetResponseHeaders(asset.path, asset);
146 if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) {
152 const mediaFile = asset.file;
153 const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
154 const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true;
155 if (mediaFile && isVideo) {
156 // Host videos can change in place. Do not invite conditional range requests
157 // with validators that cannot establish byte-for-byte identity.
158 headers["Cache-Control"] = "private, no-store";
159 }
160 let status = 200;
161 let offset = 0n;
162 let bytesToRead: bigint | undefined;
163 if (isVideo) {
147164 headers["Accept-Ranges"] = "bytes";
148165 // If-Range requires a matching validator. A full response is safe when we cannot validate it.
149 if (rangeHeader && !ifRangeHeader) {
166 if (method === "GET" && rangeHeader && ifRangeHeader === undefined) {
150167 const fs = yield* FileSystem.FileSystem;
151 const info = yield* fs.stat(asset.path);
168 const info = mediaInfo ?? (yield* fs.stat(asset.path));
152169 const range = assetByteRange(rangeHeader, info.size);
153170 if (range?._tag === "Unsatisfiable") {
154171 return HttpServerResponse.empty({
......@@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
157174 });
158175 }
159176 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 });
177 status = 206;
178 offset = range.offset;
179 bytesToRead = range.bytesToRead;
180 headers["Content-Range"] = range.contentRange;
166181 }
167182 }
168183 }
169 return yield* HttpServerResponse.file(asset.path, { status: 200, headers });
184 if (mediaFile && mediaInfo) {
185 const size = bytesToRead ?? mediaInfo.size;
186 headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream";
187 headers["Content-Length"] = String(size);
188 if (!isVideo) {
189 headers["Last-Modified"] = mediaInfo.mtime.toUTCString();
190 headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`;
191 }
192 if (method === "HEAD" || size === 0n) {
193 return HttpServerResponse.empty({ status, headers });
194 }
195 const body = streamMediaFile(mediaFile, offset, size);
196 if (!body) {
197 return HttpServerResponse.text("File is too large to preview.", { status: 413 });
198 }
199 return HttpServerResponse.stream(body, {
200 status,
201 headers,
202 });
203 }
204 return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers });
170205});
171206
172207export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
......@@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add(
338373 asset,
339374 request.method === "GET" ? request.headers.range : undefined,
340375 request.headers["if-range"],
376 request.method === "HEAD" ? "HEAD" : "GET",
341377 ).pipe(
342378 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
343379 );
apps/web/src/assets/assetUrls.ts+18-1
......@@ -1,11 +1,13 @@
11import { useAtomValue } from "@effect/atom-react";
22import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
34import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
45import { AsyncResult } from "effect/unstable/reactivity";
5import { useMemo } from "react";
6import { useCallback, useMemo } from "react";
67
78import { assetEnvironment } from "~/state/assets";
89import { usePreparedConnection } from "~/state/session";
10import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
911
1012export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1113
......@@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc
4951 return result.url;
5052}
5153
54/** Re-mints an exact-file capability after a file change or an explicit retry. */
55export function useAssetUrlRefresh(
56 environmentId: EnvironmentId,
57 resource: AssetResource,
58): () => Promise<void> {
59 const refresh = useAtomQueryRunner(assetEnvironment.createUrl, {
60 reportFailure: false,
61 refresh: true,
62 });
63 return useCallback(async () => {
64 const result = await refresh({ environmentId, input: { resource } });
65 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
66 }, [environmentId, resource, refresh]);
67}
68
5269export function useAssetUrls(
5370 environmentId: EnvironmentId,
5471 resources: ReadonlyArray<AssetResource>,
apps/web/src/components/ChatMarkdown.tsx+357-60
......@@ -42,6 +42,9 @@ import {
4242 classifyMarkdownImageSource,
4343 markdownImageSourceFragment,
4444} from "@t3tools/client-runtime/markdown-images";
45import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
46import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
47import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
4548import * as Cause from "effect/Cause";
4649import { AsyncResult } from "effect/unstable/reactivity";
4750import React, {
......@@ -76,7 +79,14 @@ import {
7679 renderCodexFileCitationsAsMarkdown,
7780} from "@t3tools/client-runtime/codex-markdown-directives";
7881import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
79import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
82import {
83 resolveMarkdownMediaPreview,
84 type ExpandedImagePreview,
85} from "./chat/ExpandedImagePreview";
86import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
87import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
88import { MediaActions, type MediaActionSource } from "./media/MediaActions";
89import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
8090import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
8191import { PierreEntryIcon } from "./chat/PierreEntryIcon";
8292import {
......@@ -126,7 +136,7 @@ import {
126136 type MarkdownFileLinkMeta,
127137} from "../markdown-links";
128138import { readLocalApi } from "../localApi";
129import { useAssetUrlState } from "../assets/assetUrls";
139import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
130140import { cn } from "../lib/utils";
131141import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
132142import { useRightPanelStore } from "../rightPanelStore";
......@@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: {
195205 canOpenInEditor: boolean;
196206 canOpenInBrowser: boolean;
197207 canOpenInPanel: boolean;
208 canOpenMedia?: boolean;
198209}): boolean {
199 return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel;
210 return (
211 input.canOpenInEditor ||
212 input.canOpenInBrowser ||
213 input.canOpenInPanel ||
214 input.canOpenMedia === true
215 );
200216}
201217
202218export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
......@@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps {
10111027 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
10121028 openInEditorMenuLabel: string;
10131029 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1030 onOpenMedia?: (() => void) | undefined;
10141031 onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
10151032 /** Platform-specific menu label ("Reveal in Finder", ...); required for the
10161033 reveal item to show. */
......@@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
11331150 );
11341151});
11351152
1136const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]";
1153const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]";
1154const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn(
1155 "max-h-[30rem]",
1156 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1157);
1158const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!";
1159const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40";
11371160const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn(
11381161 "h-auto w-auto object-contain",
1139 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,
1162 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
11401163);
11411164
11421165function markdownImageCopy(alt: string, src: string, title: string | undefined): string {
......@@ -1167,11 +1190,10 @@ function authoredImageSizeStyle(
11671190 return undefined;
11681191}
11691192
1170const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!";
11711193const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
11721194 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1173 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1174 "rounded-lg border border-border/40",
1195 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1196 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
11751197);
11761198const MarkdownLinkContext = React.createContext(false);
11771199
......@@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps(
11791201 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
11801202 src: string,
11811203 alt: string,
1204 originalUrl?: string,
1205 actionsSource?: MediaActionSource,
11821206) {
11831207 if (!onImageExpand) return {};
11841208 const previewName = alt.trim() || "image";
......@@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps(
11861210 if (event.currentTarget.closest("a")) return;
11871211 event.preventDefault();
11881212 event.stopPropagation();
1189 onImageExpand({ images: [{ src, name: previewName }], index: 0 });
1213 onImageExpand({
1214 images: [
1215 {
1216 src,
1217 name: previewName,
1218 ...(originalUrl ? { originalUrl } : {}),
1219 ...(actionsSource ? { actionsSource } : {}),
1220 },
1221 ],
1222 index: 0,
1223 });
11901224 };
11911225 return {
11921226 role: "button" as const,
......@@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps(
12021236function ChatMarkdownImageFallback(props: {
12031237 readonly alt: string;
12041238 readonly copyMarkdown?: string | undefined;
1239 readonly kind?: "image" | "video";
1240 readonly actionsSource?: MediaActionSource;
12051241}) {
1206 return (
1242 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1243 const content = (
12071244 <span
12081245 data-markdown-copy={props.copyMarkdown}
12091246 className={cn(
1210 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1247 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
12111248 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
12121249 )}
12131250 >
12141251 <span className="inline-flex items-center gap-1.5">
12151252 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1216 {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"}
1253 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
12171254 </span>
12181255 </span>
12191256 );
1257 return props.actionsSource ? (
1258 <MediaActions source={props.actionsSource}>{content}</MediaActions>
1259 ) : (
1260 content
1261 );
1262}
1263
1264function ChatMarkdownVideo(props: {
1265 readonly src: string | null;
1266 readonly alt: string;
1267 readonly copyMarkdown: string | undefined;
1268 readonly originalUrl?: string | undefined;
1269 readonly sourceFailed?: boolean | undefined;
1270 readonly style?: CSSProperties | undefined;
1271 readonly mediaIdentity?: string | undefined;
1272 readonly actionsSource?: MediaActionSource | undefined;
1273 readonly onRetry?: (() => Promise<void>) | undefined;
1274 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1275}) {
1276 return (
1277 <MediaVideoPlayer
1278 key={props.mediaIdentity ?? props.copyMarkdown ?? props.src}
1279 src={props.src}
1280 sourceFailed={props.sourceFailed}
1281 label={props.alt}
1282 originalUrl={props.originalUrl}
1283 style={props.style}
1284 copyMarkdown={props.copyMarkdown}
1285 className={cn(
1286 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1287 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1288 "w-full",
1289 )}
1290 videoClassName={cn(
1291 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1292 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1293 )}
1294 onRetry={props.onRetry}
1295 actionsSource={props.actionsSource}
1296 onExpand={
1297 props.onImageExpand
1298 ? (src) => {
1299 props.onImageExpand?.({
1300 images: [
1301 {
1302 src,
1303 name: props.alt || "video",
1304 type: "video",
1305 autoPlay: false,
1306 ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}),
1307 ...(props.actionsSource
1308 ? { actionsSource: { ...props.actionsSource, src } }
1309 : {}),
1310 },
1311 ],
1312 index: 0,
1313 });
1314 }
1315 : undefined
1316 }
1317 />
1318 );
12201319}
12211320
1222/** Environment-hosted images load through a signed asset URL. */
1321/** Environment-hosted media loads through an exact-file signed asset URL. */
12231322export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
12241323 readonly environmentId: EnvironmentId;
1225 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
1324 readonly resource: Extract<
1325 AssetResource,
1326 { readonly _tag: "attachment" | "workspace-file" | "media-file" }
1327 >;
1328 readonly kind?: "image" | "video";
12261329 readonly alt: string;
12271330 readonly copyMarkdown?: string;
12281331 readonly srcFragment?: string;
12291332 readonly style?: CSSProperties | undefined;
1333 readonly workspaceRoot?: string | undefined;
12301334 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
12311335}) {
12321336 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
1337 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
12331338 const [failedUrl, setFailedUrl] = useState<string | null>(null);
1339 const resource = props.resource;
1340 const path =
1341 resource._tag === "media-file"
1342 ? resource.path
1343 : resource._tag === "workspace-file" && props.workspaceRoot
1344 ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`
1345 : undefined;
1346 const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined;
1347 const relativePath = reference?.relativePath;
1348 const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null;
1349 const actionsSource: MediaActionSource = {
1350 kind: props.kind ?? "image",
1351 name: props.alt || (props.kind ?? "image"),
1352 src,
1353 asset: { environmentId: props.environmentId, resource },
1354 ...(reference ? { reference } : {}),
1355 ...(relativePath && resource._tag !== "attachment"
1356 ? {
1357 onOpenFile: () =>
1358 useRightPanelStore
1359 .getState()
1360 .openFile(
1361 { environmentId: props.environmentId, threadId: resource.threadId },
1362 relativePath,
1363 ),
1364 }
1365 : {}),
1366 };
1367
1368 if (props.kind === "video") {
1369 return (
1370 <ChatMarkdownVideo
1371 src={src}
1372 sourceFailed={assetUrl._tag === "Failure"}
1373 alt={props.alt}
1374 copyMarkdown={props.copyMarkdown}
1375 style={props.style}
1376 mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1377 onRetry={refreshAssetUrl}
1378 onImageExpand={props.onImageExpand}
1379 actionsSource={actionsSource}
1380 />
1381 );
1382 }
12341383
12351384 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
1236 return <ChatMarkdownImageFallback alt={props.alt} copyMarkdown={props.copyMarkdown} />;
1385 return (
1386 <ChatMarkdownImageFallback
1387 alt={props.alt}
1388 copyMarkdown={props.copyMarkdown}
1389 kind={props.kind ?? "image"}
1390 actionsSource={actionsSource}
1391 />
1392 );
12371393 }
12381394 if (assetUrl._tag !== "Success") {
12391395 return (
1240 <span
1396 <MediaActions source={actionsSource}>
1397 <span
1398 data-markdown-copy={props.copyMarkdown}
1399 role="status"
1400 aria-label="Loading image"
1401 className={cn(
1402 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1403 "aspect-video w-64 max-w-full rounded-lg bg-muted/60",
1404 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1405 )}
1406 style={props.style}
1407 />
1408 </MediaActions>
1409 );
1410 }
1411 return (
1412 <MediaActions source={actionsSource}>
1413 <img
1414 src={src ?? undefined}
1415 alt={props.alt}
12411416 data-markdown-copy={props.copyMarkdown}
1242 role="status"
1243 aria-label="Loading image"
1417 loading="lazy"
1418 draggable={false}
12441419 className={cn(
1245 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1246 "aspect-video w-64 max-w-full rounded-lg bg-muted/60",
1247 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,
1420 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1421 props.onImageExpand && "cursor-zoom-in",
12481422 )}
12491423 style={props.style}
1424 {...expandableMarkdownImageProps(
1425 props.onImageExpand,
1426 src ?? assetUrl.url,
1427 props.alt,
1428 undefined,
1429 actionsSource,
1430 )}
1431 onError={() => setFailedUrl(assetUrl.url)}
12501432 />
1251 );
1252 }
1253 const src = assetUrl.url + (props.srcFragment ?? "");
1254 return (
1255 <img
1256 src={src}
1257 alt={props.alt}
1258 data-markdown-copy={props.copyMarkdown}
1259 loading="lazy"
1260 draggable={false}
1261 className={cn(
1262 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1263 props.onImageExpand && "cursor-zoom-in",
1264 )}
1265 style={props.style}
1266 {...expandableMarkdownImageProps(props.onImageExpand, src, props.alt)}
1267 onError={() => setFailedUrl(assetUrl.url)}
1268 />
1433 </MediaActions>
12691434 );
12701435});
12711436
......@@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
14481613 onOpenInPanel,
14491614 openInEditorMenuLabel,
14501615 onOpenInBrowser,
1616 onOpenMedia,
14511617 onReveal,
14521618 revealLabel,
14531619 className,
......@@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
14911657 }, [onOpen, targetPath]);
14921658
14931659 const handleOpenInFilePreview = useCallback(() => {
1494 if (!threadRef || !workspaceRelativePath) {
1495 handleOpenInEditor();
1660 if (threadRef && workspaceRelativePath) {
1661 onOpenInPanel(workspaceRelativePath, line);
14961662 return;
14971663 }
1498 onOpenInPanel(workspaceRelativePath, line);
1499 }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]);
1664 if (onOpenMedia) {
1665 onOpenMedia();
1666 return;
1667 }
1668 handleOpenInEditor();
1669 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);
15001670
15011671 const handleOpenInBrowser = useCallback(() => {
15021672 if (!onOpenInBrowser) {
......@@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16211791 try {
16221792 const clicked = await api.contextMenu.show(
16231793 [
1794 ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []),
16241795 ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []),
16251796 ...(onOpenInBrowser
16261797 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
......@@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16321803 position,
16331804 );
16341805
1806 if (clicked === "preview-media") {
1807 onOpenMedia?.();
1808 return;
1809 }
16351810 if (clicked === "open") {
16361811 handleOpenInEditor();
16371812 return;
......@@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16651840 handleOpenInEditor,
16661841 handleRevealInFileManager,
16671842 onOpenInBrowser,
1843 onOpenMedia,
16681844 onOpen,
16691845 onReveal,
16701846 openInEditorMenuLabel,
......@@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16961872 canOpenInEditor,
16971873 canOpenInBrowser,
16981874 canOpenInPanel,
1875 canOpenMedia: onOpenMedia !== undefined,
16991876 });
17001877 const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({
17011878 iconPath,
......@@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual(
17871964 previous.onOpenInPanel === next.onOpenInPanel &&
17881965 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&
17891966 previous.onOpenInBrowser === next.onOpenInBrowser &&
1967 previous.onOpenMedia === next.onOpenMedia &&
17901968 previous.onReveal === next.onReveal &&
17911969 previous.revealLabel === next.revealLabel &&
17921970 previous.className === next.className
......@@ -1810,8 +1988,18 @@ function ChatMarkdown({
18101988 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
18111989}: ChatMarkdownProps) {
18121990 const { resolvedTheme } = useTheme();
1991 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
1992 const expandMedia = onImageExpand ?? setLocalMediaPreview;
1993 const mediaRequestId = useRef(0);
1994 useEffect(() => {
1995 setLocalMediaPreview(null);
1996 return () => {
1997 mediaRequestId.current += 1;
1998 };
1999 }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]);
18132000 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
18142001 reportFailure: false,
2002 refresh: true,
18152003 });
18162004 const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
18172005 reportFailure: false,
......@@ -1830,6 +2018,41 @@ function ChatMarkdown({
18302018 remoteOpen.isResolved,
18312019 );
18322020 const preparedConnection = usePreparedConnection(environmentId);
2021 const openMarkdownMedia = useCallback(
2022 (source: string, resolvedFilePath?: string) => {
2023 const requestId = ++mediaRequestId.current;
2024 void resolveMarkdownMediaPreview({
2025 source,
2026 resolvedFilePath,
2027 cwd,
2028 threadRef,
2029 httpBaseUrl:
2030 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined,
2031 createAssetUrl,
2032 onOpenFile: threadRef
2033 ? (path) => useRightPanelStore.getState().openFile(threadRef, path)
2034 : undefined,
2035 }).then(
2036 (preview) => {
2037 if (preview && mediaRequestId.current === requestId) expandMedia(preview);
2038 },
2039 (error: unknown) => {
2040 if (mediaRequestId.current !== requestId) return;
2041 toastManager.add(
2042 stackedThreadToast({
2043 type: "error",
2044 title: "Media unavailable",
2045 description:
2046 error instanceof Error
2047 ? error.message
2048 : "The file could not be loaded. It may have been moved or deleted.",
2049 }),
2050 );
2051 },
2052 );
2053 },
2054 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],
2055 );
18332056 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
18342057 const threadServerConfig = useAtomValue(
18352058 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
......@@ -2068,6 +2291,7 @@ function ChatMarkdown({
20682291 fileLinkMeta: MarkdownFileLinkMeta,
20692292 copyMarkdown: string,
20702293 className?: string,
2294 mediaSource?: string,
20712295 ) => {
20722296 const parentSuffix = fileLinkParentSuffixByPath.get(
20732297 fileLinkMeta.filePath.replaceAll("\\", "/"),
......@@ -2081,6 +2305,11 @@ function ChatMarkdown({
20812305 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
20822306 );
20832307 }
2308 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
2309 const canPreviewMedia =
2310 mediaMimeTypeFromExtension(
2311 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
2312 ) !== null;
20842313
20852314 return (
20862315 <MarkdownFileLink
......@@ -2096,6 +2325,11 @@ function ChatMarkdown({
20962325 threadRef={threadRef}
20972326 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
20982327 onOpenInPanel={openFileInPanel}
2328 onOpenMedia={
2329 threadRef && canPreviewMedia
2330 ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath)
2331 : undefined
2332 }
20992333 openInEditorMenuLabel={preferredEditorMenuLabel}
21002334 onReveal={
21012335 canUseShellActions && revealInFileManagerLabel !== undefined
......@@ -2232,6 +2466,21 @@ function ChatMarkdown({
22322466 handleMarkdownFragmentClick(event, href);
22332467 return;
22342468 }
2469 if (
2470 href &&
2471 faviconHost !== null &&
2472 mediaKindFromPath(href) !== null &&
2473 !event.defaultPrevented &&
2474 !event.metaKey &&
2475 !event.ctrlKey &&
2476 !event.shiftKey &&
2477 !event.altKey
2478 ) {
2479 event.preventDefault();
2480 event.stopPropagation();
2481 openMarkdownMedia(href);
2482 return;
2483 }
22352484 // A link to a change request in a workspace project opens beside the
22362485 // conversation instead of in a browser: it is the thing being talked about, and
22372486 // the panel it opens offers the browser as one of its actions. Anything else is
......@@ -2323,6 +2572,7 @@ function ChatMarkdown({
23232572 fileLinkMeta,
23242573 `[${fileLinkMeta.basename}](${normalizedHref})`,
23252574 props.className,
2575 normalizedHref,
23262576 );
23272577 },
23282578 code({ node, children, className, ...props }) {
......@@ -2332,7 +2582,12 @@ function ChatMarkdown({
23322582 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
23332583 resolveInlineCodeFileLinkMeta(codeText, cwd);
23342584 if (fileLinkMeta) {
2335 return fileLinkChip(fileLinkMeta, `\`${codeText}\``);
2585 return fileLinkChip(
2586 fileLinkMeta,
2587 `\`${codeText}\``,
2588 undefined,
2589 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
2590 );
23362591 }
23372592 }
23382593 return (
......@@ -2342,7 +2597,7 @@ function ChatMarkdown({
23422597 );
23432598 },
23442599 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2345 const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand;
2600 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
23462601 const localSrc = node?.properties?.dataLocalSrc;
23472602 const markdownTitle = node?.properties?.dataMarkdownTitle;
23482603 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
......@@ -2355,21 +2610,53 @@ function ChatMarkdown({
23552610 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
23562611 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
23572612 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2613 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
23582614 if (imageSource._tag === "Direct") {
2615 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);
2616 const originalUrl =
2617 resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined;
2618 const reference = mediaUrlReference(imageSource.uri);
2619 const actionsSource: MediaActionSource = {
2620 kind,
2621 name: altText || kind,
2622 src: mediaSrc,
2623 ...(reference ? { reference } : {}),
2624 };
2625 if (kind === "video") {
2626 return (
2627 <ChatMarkdownVideo
2628 src={mediaSrc}
2629 alt={altText}
2630 copyMarkdown={copyMarkdown}
2631 originalUrl={originalUrl}
2632 style={authoredSizeStyle}
2633 onImageExpand={imageExpand}
2634 actionsSource={actionsSource}
2635 />
2636 );
2637 }
23592638 return (
2360 <img
2361 {...props}
2362 src={imageSource.uri}
2363 alt={altText}
2364 loading="lazy"
2365 className={cn(
2366 props.className,
2367 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2368 imageExpand && "cursor-zoom-in",
2369 )}
2370 style={authoredSizeStyle}
2371 {...expandableMarkdownImageProps(imageExpand, imageSource.uri, altText)}
2372 />
2639 <MediaActions source={actionsSource}>
2640 <img
2641 {...props}
2642 src={mediaSrc}
2643 alt={altText}
2644 loading="lazy"
2645 className={cn(
2646 props.className,
2647 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2648 imageExpand && "cursor-zoom-in",
2649 )}
2650 style={authoredSizeStyle}
2651 {...expandableMarkdownImageProps(
2652 imageExpand,
2653 mediaSrc,
2654 altText,
2655 originalUrl,
2656 actionsSource,
2657 )}
2658 />
2659 </MediaActions>
23732660 );
23742661 }
23752662 if (imageSource._tag === "WorkspaceFile" && threadRef) {
......@@ -2377,19 +2664,21 @@ function ChatMarkdown({
23772664 <ChatMarkdownAssetImage
23782665 environmentId={threadRef.environmentId}
23792666 resource={{
2380 _tag: "workspace-file",
2667 _tag: "media-file",
23812668 threadId: threadRef.threadId,
23822669 path: imageSource.path,
23832670 }}
23842671 alt={altText}
2672 kind={kind}
23852673 copyMarkdown={copyMarkdown}
23862674 srcFragment={markdownImageSourceFragment(classifiedSrc)}
23872675 style={authoredSizeStyle}
2676 workspaceRoot={cwd}
23882677 onImageExpand={imageExpand}
23892678 />
23902679 );
23912680 }
2392 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} />;
2681 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
23932682 },
23942683 table({ node: _node, ...props }) {
23952684 return <MarkdownTable {...props} />;
......@@ -2438,6 +2727,8 @@ function ChatMarkdown({
24382727 onTaskListChange,
24392728 onUseArtifactTemplate,
24402729 onImageExpand,
2730 expandMedia,
2731 openMarkdownMedia,
24412732 openFileInPanel,
24422733 openInPreferredEditor,
24432734 openChangeRequestLink,
......@@ -2483,6 +2774,12 @@ function ChatMarkdown({
24832774 >
24842775 {text}
24852776 </ReactMarkdown>
2777 {localMediaPreview ? (
2778 <ExpandedImageDialog
2779 preview={localMediaPreview}
2780 onClose={() => setLocalMediaPreview(null)}
2781 />
2782 ) : null}
24862783 </div>
24872784 );
24882785}
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+7-6
......@@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({
99
1010vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
1111vi.mock("../assets/assetUrls", () => ({
12 useAssetUrlRefresh: () => vi.fn(),
1213 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
1314 testState.resources.push(resource);
1415 if (testState.assetState === "loading") return { _tag: "Loading" };
......@@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => {
106107
107108 expect(testState.resources).toEqual([
108109 {
109 _tag: "workspace-file",
110 _tag: "media-file",
110111 threadId: threadRef.threadId,
111112 path: expectedPath,
112113 },
......@@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => {
126127
127128 expect(testState.resources).toEqual([
128129 {
129 _tag: "workspace-file",
130 _tag: "media-file",
130131 threadId: threadRef.threadId,
131132 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
132133 },
133 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
134 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
134 { _tag: "media-file", threadId: threadRef.threadId, path: imagePath },
135 { _tag: "media-file", threadId: threadRef.threadId, path: imagePath },
135136 {
136 _tag: "workspace-file",
137 _tag: "media-file",
137138 threadId: threadRef.threadId,
138139 path: "\\\\server\\share\\workspace-image.svg",
139140 },
......@@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => {
149150
150151 expect(testState.resources).toEqual([
151152 {
152 _tag: "workspace-file",
153 _tag: "media-file",
153154 threadId: threadRef.threadId,
154155 path: "D:/screens/workspace-image.svg",
155156 },
apps/web/src/components/ChatView.logic.test.ts-18
......@@ -22,7 +22,6 @@ import {
2222 dismissBranchMismatchForSession,
2323 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
2424 getStartedThreadModelChangeBlockReason,
25 loadVideoPreviewUrl,
2625 isVideoPreviewRequestCurrent,
2726 hasEnvironmentReconnectWarningGraceElapsed,
2827 hasServerAcknowledgedLocalDispatch,
......@@ -44,23 +43,6 @@ import {
4443 shouldWriteThreadErrorToCurrentServerThread,
4544} from "./ChatView.logic";
4645
47describe("loadVideoPreviewUrl", () => {
48 it("loads video bytes into an object URL", async () => {
49 const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA==");
50 expect(objectUrl).toMatch(/^blob:/);
51 URL.revokeObjectURL(objectUrl);
52 });
53
54 it("stops loading when the preview request is cancelled", async () => {
55 const controller = new AbortController();
56 controller.abort();
57
58 await expect(
59 loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal),
60 ).rejects.toMatchObject({ name: "AbortError" });
61 });
62});
63
6446describe("isVideoPreviewRequestCurrent", () => {
6547 it("rejects changed threads and replaced previews", () => {
6648 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
apps/web/src/components/ChatView.logic.ts+35-4
......@@ -1,4 +1,7 @@
11import {
2 type AssetCreateUrlInput,
3 type AssetCreateUrlResult,
4 type ChatFileAttachment,
25 type EnvironmentId,
36 isProviderDriverKind,
47 ProjectId,
......@@ -12,6 +15,12 @@ import {
1215 type ThreadId,
1316 type TurnId,
1417} from "@t3tools/contracts";
18import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
19import {
20 squashAtomCommandFailure,
21 type AtomCommandResult,
22} from "@t3tools/client-runtime/state/runtime";
23import { videoMimeType } from "@t3tools/shared/video";
1524import {
1625 appendCodexArtifactTemplateUsePrompt,
1726 codexArtifactTemplateUsePrompt,
......@@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
299308 URL.revokeObjectURL(previewUrl);
300309}
301310
302export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise<string> {
303 const response = await fetch(url, signal ? { signal } : {});
304 if (!response.ok) throw new Error(`Could not load video (${response.status}).`);
305 return URL.createObjectURL(await response.blob());
311/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */
312export async function resolveFileAttachmentUrl(input: {
313 attachment: ChatFileAttachment;
314 environmentId: EnvironmentId;
315 httpBaseUrl: string;
316 createAssetUrl: (input: {
317 environmentId: EnvironmentId;
318 input: AssetCreateUrlInput;
319 }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>;
320}): Promise<string> {
321 const { attachment } = input;
322 const result = await input.createAssetUrl({
323 environmentId: input.environmentId,
324 input: {
325 resource: {
326 _tag: "attachment",
327 attachmentId: attachment.id,
328 fileName: attachment.name,
329 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
330 },
331 },
332 });
333 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
334 const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
335 if (url === null) throw new Error("The environment returned an invalid attachment URL.");
336 return url;
306337}
307338
308339export function isVideoPreviewRequestCurrent(
apps/web/src/components/ChatView.tsx+27-73
......@@ -365,7 +365,7 @@ import {
365365 cloneComposerImageForRetry,
366366 deriveLockedProvider,
367367 readFileAsDataUrl,
368 loadVideoPreviewUrl,
368 resolveFileAttachmentUrl,
369369 isVideoPreviewRequestCurrent,
370370 reconcileMountedTerminalThreadIds,
371371 resolveBackgroundDraftWorkspaceOptions,
......@@ -424,7 +424,7 @@ import {
424424 resolveServerSelfUpdateCapability,
425425 serverUpdateGuidance,
426426} from "../versionSkew";
427import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls";
427import { useAssetUrls } from "../assets/assetUrls";
428428
429429const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
430430 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
......@@ -1331,6 +1331,7 @@ function ChatViewContent(props: ChatViewProps) {
13311331 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
13321332 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
13331333 reportFailure: false,
1334 refresh: true,
13341335 });
13351336 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
13361337 reportFailure: false,
......@@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) {
14591460 const routeThreadKeyRef = useRef(routeThreadKey);
14601461 routeThreadKeyRef.current = routeThreadKey;
14611462 const videoPreviewRequestIdRef = useRef(0);
1462 const videoPreviewAbortControllerRef = useRef<AbortController | null>(null);
14631463 const cancelVideoPreviewRequest = useCallback(() => {
14641464 videoPreviewRequestIdRef.current += 1;
1465 videoPreviewAbortControllerRef.current?.abort();
1466 videoPreviewAbortControllerRef.current = null;
14671465 }, []);
14681466 const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);
14691467 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
......@@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) {
25422540 toastManager.add({ type: "error", title: "The environment is not connected." });
25432541 return;
25442542 }
2545 const videoMime = videoMimeType(attachment);
2546 const isVideo = videoMime !== null;
2543 const isVideo = videoMimeType(attachment) !== null;
25472544 const action = isVideo ? "play" : "download";
2548 const videoPreviewAbortController = isVideo ? new AbortController() : null;
2549 if (isVideo) {
2550 videoPreviewAbortControllerRef.current?.abort();
2551 videoPreviewAbortControllerRef.current = videoPreviewAbortController;
2552 }
25532545 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
25542546 const isCurrentRequest = () =>
25552547 !isVideo ||
......@@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) {
25592551 videoPreviewRequestId,
25602552 videoPreviewRequestIdRef.current,
25612553 );
2562 const finishVideoPreviewRequest = () => {
2563 if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
2564 setOpeningVideoAttachmentId(null);
2565 videoPreviewAbortControllerRef.current = null;
2566 }
2567 };
25682554 if (isVideo) setOpeningVideoAttachmentId(attachment.id);
25692555
2570 // fileName and mimeType ride in the signed claims so videos render
2571 // inline while other files keep their real download name and type.
2572 const result = await createAttachmentAssetUrl({
2573 environmentId,
2574 input: {
2575 resource: {
2576 _tag: "attachment",
2577 attachmentId: attachment.id,
2578 fileName: attachment.name,
2579 mimeType: videoMime ?? attachment.mimeType,
2580 },
2581 },
2582 });
2583 if (!isCurrentRequest()) {
2584 finishVideoPreviewRequest();
2585 return;
2586 }
2587 if (result._tag === "Failure") {
2588 finishVideoPreviewRequest();
2589 const error = squashAtomCommandFailure(result);
2590 toastManager.add({
2591 type: "error",
2592 title: "Could not " + action + " " + attachment.name,
2593 description: error instanceof Error ? error.message : "The attachment is unavailable.",
2556 try {
2557 const url = await resolveFileAttachmentUrl({
2558 attachment,
2559 environmentId,
2560 httpBaseUrl: connection.httpBaseUrl,
2561 createAssetUrl: createAttachmentAssetUrl,
25942562 });
2595 return;
2596 }
2597
2598 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
2599 if (!url) {
2600 finishVideoPreviewRequest();
2601 toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name });
2602 return;
2603 }
2604 if (isVideo) {
2605 try {
2606 const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal);
2607 if (!isCurrentRequest()) {
2608 revokeBlobPreviewUrl(previewUrl);
2609 return;
2610 }
2563 if (!isCurrentRequest()) return;
2564 if (isVideo) {
26112565 setExpandedImage({
2612 images: [{ src: previewUrl, name: attachment.name, type: "video" }],
2566 images: [{ src: url, name: attachment.name, type: "video" }],
26132567 index: 0,
26142568 });
2615 } catch (error) {
2616 if (!isCurrentRequest()) return;
2617 toastManager.add({
2618 type: "error",
2619 title: "Could not play " + attachment.name,
2620 description: error instanceof Error ? error.message : "The attachment is unavailable.",
2621 });
2622 } finally {
2623 finishVideoPreviewRequest();
2569 return;
26242570 }
2625 return;
2571 const anchor = document.createElement("a");
2572 anchor.href = url;
2573 anchor.download = attachment.name;
2574 anchor.click();
2575 } catch (error) {
2576 if (!isCurrentRequest()) return;
2577 toastManager.add({
2578 type: "error",
2579 title: "Could not " + action + " " + attachment.name,
2580 description: error instanceof Error ? error.message : "The attachment is unavailable.",
2581 });
2582 } finally {
2583 if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null);
26262584 }
2627 const anchor = document.createElement("a");
2628 anchor.href = url;
2629 anchor.download = attachment.name;
2630 anchor.click();
26312585 },
26322586 [createAttachmentAssetUrl, environmentId, routeThreadKey],
26332587 );
apps/web/src/components/chat/ChatComposer.tsx+2-3
......@@ -172,6 +172,7 @@ import {
172172 submitComposerDraft,
173173} from "./composerSubmission";
174174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175176
176177function ComposerVideoThumbnail({ file }: { file: File }) {
177178 const setVideo = useCallback(
......@@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) {
189190 playsInline
190191 preload="metadata"
191192 aria-hidden="true"
192 onLoadedMetadata={(event) => {
193 event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0);
194 }}
193 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
195194 className="pointer-events-none absolute inset-0 size-full object-cover"
196195 />
197196 );
apps/web/src/components/chat/ExpandedImageDialog.test.tsx deleted-28
......@@ -1,28 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ExpandedImageDialog } from "./ExpandedImageDialog";
5import type { ExpandedImagePreview } from "./ExpandedImagePreview";
6
7describe("ExpandedImageDialog", () => {
8 it("renders video previews with native controls", () => {
9 const preview: ExpandedImagePreview = {
10 images: [
11 {
12 src: "https://environment.test/api/assets/demo.mp4",
13 name: "demo.mp4",
14 type: "video",
15 },
16 ],
17 index: 0,
18 };
19
20 const markup = renderToStaticMarkup(
21 <ExpandedImageDialog preview={preview} onClose={() => {}} />,
22 );
23
24 expect(markup).toContain("<video");
25 expect(markup).toContain('controls=""');
26 expect(markup).toContain('aria-label="demo.mp4"');
27 });
28});
apps/web/src/components/chat/ExpandedImageDialog.tsx+100-77
......@@ -1,41 +1,64 @@
1import { memo, useCallback, useEffect, useState } from "react";
2import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react";
1import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
2import { createPortal } from "react-dom";
3import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
34import { Button } from "../ui/button";
4import { downloadVideoPreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
5import type { ExpandedImagePreview } from "./ExpandedImagePreview";
6import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
7import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
8import { OpenMediaLink } from "../media/OpenMediaLink";
9import { MediaActions, type MediaActionSource } from "../media/MediaActions";
10import { isContextMenuOpen } from "../../contextMenuFallback";
511
612interface ExpandedImageDialogProps {
713 preview: ExpandedImagePreview;
814 onClose: () => void;
915}
1016
17function ExpandedMediaFailure({ children }: { children: ReactNode }) {
18 return (
19 <div
20 role="alert"
21 className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-sm text-white shadow-2xl"
22 >
23 {children}
24 </div>
25 );
26}
27
1128export const ExpandedImageDialog = memo(function ExpandedImageDialog({
1229 preview,
1330 onClose,
1431}: ExpandedImageDialogProps) {
1532 const [imageOffset, setImageOffset] = useState(0);
1633 const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null);
17 const [downloadingVideoSrc, setDownloadingVideoSrc] = useState<string | null>(null);
18 const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState<string | null>(null);
34 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
1935 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
36 const item = preview.images[index];
37 const source: MediaActionSource = item?.actionsSource ?? {
38 kind: item?.type === "video" ? "video" : "image",
39 name: item?.name ?? "Media",
40 src: item?.src ?? null,
41 };
42 const openFile = source.onOpenFile;
43 const actionsSource: MediaActionSource = openFile
44 ? {
45 ...source,
46 onOpenFile: () => {
47 openFile();
48 onClose();
49 },
50 }
51 : source;
2052
2153 const navigateImage = useCallback((direction: -1 | 1) => {
2254 setImageOffset((current) => current + direction);
2355 }, []);
2456
25 const downloadVideo = async (src: string, name: string) => {
26 setDownloadFailedVideoSrc(null);
27 setDownloadingVideoSrc(src);
28 try {
29 await downloadVideoPreview(src, name);
30 } catch {
31 setDownloadFailedVideoSrc(src);
32 } finally {
33 setDownloadingVideoSrc((current) => (current === src ? null : current));
34 }
35 };
36
3757 useEffect(() => {
3858 const onKeyDown = (event: globalThis.KeyboardEvent) => {
59 if (event.defaultPrevented || isContextMenuOpen()) {
60 return;
61 }
3962 if (event.key === "Escape") {
4063 event.preventDefault();
4164 event.stopPropagation();
......@@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
5881 return () => window.removeEventListener("keydown", onKeyDown);
5982 }, [navigateImage, onClose, preview.images.length]);
6083
61 const item = preview.images[index];
6284 if (!item) return null;
6385 const mediaLabel = item.type === "video" ? "video" : "image";
86 const openOriginalLink =
87 item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? (
88 <OpenMediaLink originalUrl={item.originalUrl} />
89 ) : null;
6490
65 const isDownloadingVideo = downloadingVideoSrc === item.src;
66 const videoDownloadFailed = downloadFailedVideoSrc === item.src;
67 return (
91 return createPortal(
6892 <div
6993 className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
7094 role="dialog"
......@@ -89,61 +113,59 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
89113 <ChevronLeftIcon className="size-5" />
90114 </Button>
91115 )}
92 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
93 <Button
94 type="button"
95 size="icon-xs"
96 variant="ghost"
97 className="absolute right-2 top-2"
98 onClick={onClose}
99 aria-label={`Close ${mediaLabel} preview`}
100 >
101 <XIcon />
102 </Button>
103 {item.type === "video" && failedVideoSrc === item.src ? (
104 <div className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-white shadow-2xl">
105 <p className="text-sm">
106 {videoDownloadFailed
107 ? "Could not download this video."
108 : "This video format cannot be played here."}
109 </p>
110 <Button
111 size="sm"
112 variant="secondary"
113 aria-busy={isDownloadingVideo || undefined}
114 aria-disabled={isDownloadingVideo || undefined}
115 onClick={() => {
116 if (isDownloadingVideo) return;
117 void downloadVideo(item.src, item.name);
118 }}
119 >
120 <DownloadIcon />
121 {isDownloadingVideo ? "Downloading…" : "Download video"}
122 </Button>
123 </div>
124 ) : item.type === "video" ? (
125 <video
126 src={item.src}
127 aria-label={item.name}
128 autoPlay
129 controls
130 playsInline
131 onError={() => setFailedVideoSrc(item.src)}
132 className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl"
133 />
134 ) : (
135 <img
136 src={item.src}
137 alt={item.name}
138 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
139 draggable={false}
140 />
141 )}
142 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
143 {item.name}
144 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
145 </p>
146 </div>
116 <MediaActions source={actionsSource}>
117 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
118 <Button
119 type="button"
120 size="icon-xs"
121 variant="ghost"
122 className="absolute right-2 top-2 z-20"
123 onClick={onClose}
124 aria-label={`Close ${mediaLabel} preview`}
125 >
126 <XIcon />
127 </Button>
128 {item.type === "video" && failedVideoSrc === item.src ? (
129 <ExpandedMediaFailure>
130 <p>This video could not be loaded or played.</p>
131 <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} />
132 </ExpandedMediaFailure>
133 ) : item.type === "video" ? (
134 <video
135 src={item.src}
136 aria-label={item.name}
137 autoPlay={item.autoPlay ?? true}
138 preload="metadata"
139 controls
140 playsInline
141 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
142 onError={() => setFailedVideoSrc(item.src)}
143 className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl"
144 />
145 ) : failedImageSrc === item.src ? (
146 <ExpandedMediaFailure>
147 <p>
148 {openOriginalLink
149 ? "This image could not be loaded."
150 : "Image unavailable. The file may have been moved or deleted."}
151 </p>
152 {openOriginalLink}
153 </ExpandedMediaFailure>
154 ) : (
155 <img
156 src={item.src}
157 alt={item.name}
158 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
159 draggable={false}
160 onError={() => setFailedImageSrc(item.src)}
161 />
162 )}
163 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
164 {item.name}
165 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
166 </p>
167 </div>
168 </MediaActions>
147169 {preview.images.length > 1 && (
148170 <Button
149171 type="button"
......@@ -156,6 +178,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
156178 <ChevronRightIcon className="size-5" />
157179 </Button>
158180 )}
159 </div>
181 </div>,
182 document.body,
160183 );
161184});
apps/web/src/components/chat/ExpandedImagePreview.test.ts+36-27
......@@ -1,12 +1,46 @@
1import { describe, expect, it, vi } from "vite-plus/test";
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
33import type { ComposerFileAttachment } from "../../composerDraftStore";
44import {
55 attachVideoThumbnail,
66 buildExpandedImagePreview,
7 downloadVideoPreview,
7 resolveMarkdownMediaPreview,
88} from "./ExpandedImagePreview";
99
10describe("resolveMarkdownMediaPreview", () => {
11 afterEach(() => {
12 vi.unstubAllGlobals();
13 });
14
15 it.each([
16 ["t3code:", "https:"],
17 ["t3code-dev:", "https:"],
18 ["http:", "http:"],
19 ["https:", "https:"],
20 ])(
21 "resolves protocol-relative media on %s without changing its source",
22 async (pageProtocol, mediaProtocol) => {
23 vi.stubGlobal("window", { location: { protocol: pageProtocol } });
24 const source = "//cdn.example.com/recording.mp4?token=a%2FB&v=1#t=2";
25 const preview = await resolveMarkdownMediaPreview({
26 source,
27 createAssetUrl: async () => {
28 throw new Error("Remote media must not request a local asset URL.");
29 },
30 });
31
32 expect(preview?.images[0]).toMatchObject({
33 src: `${mediaProtocol}${source}`,
34 originalUrl: source,
35 actionsSource: {
36 src: `${mediaProtocol}${source}`,
37 reference: { kind: "url", url: source },
38 },
39 });
40 },
41 );
42});
43
1044describe("buildExpandedImagePreview", () => {
1145 it("builds a video preview for a local video attachment", () => {
1246 const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" });
......@@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => {
4074 detach();
4175 await expect(fetch(url)).rejects.toThrow();
4276 });
43
44 it("downloads a video through a local blob URL", async () => {
45 vi.useFakeTimers();
46 const source = URL.createObjectURL(new Blob([new Uint8Array([1, 2, 3])]));
47 const click = vi.fn();
48 const anchor = { href: "", download: "", click };
49 vi.stubGlobal("document", { createElement: () => anchor });
50
51 try {
52 await downloadVideoPreview(source, "demo.mp4");
53
54 expect(anchor.download).toBe("demo.mp4");
55 expect(anchor.href).toMatch(/^blob:/);
56 expect(anchor.href).not.toBe(source);
57 expect(click).toHaveBeenCalledOnce();
58 expect((await fetch(anchor.href)).ok).toBe(true);
59
60 await vi.runAllTimersAsync();
61 await expect(fetch(anchor.href)).rejects.toThrow();
62 } finally {
63 URL.revokeObjectURL(source);
64 vi.useRealTimers();
65 vi.unstubAllGlobals();
66 }
67 });
6877});
apps/web/src/components/chat/ExpandedImagePreview.tsx+110-11
......@@ -1,10 +1,34 @@
11import type { ComposerFileAttachment } from "../../composerDraftStore";
22import { type ChatImageAttachment, isVideoAttachment } from "../../types";
3import type {
4 AssetCreateUrlResult,
5 AssetResource,
6 EnvironmentId,
7 ScopedThreadRef,
8} from "@t3tools/contracts";
9import {
10 classifyMarkdownImageSource,
11 markdownImageSourceFragment,
12} from "@t3tools/client-runtime/markdown-images";
13import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
14import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
15import {
16 squashAtomCommandFailure,
17 type AtomCommandResult,
18} from "@t3tools/client-runtime/state/runtime";
19import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
20import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
21import type { MediaActionSource } from "../media/MediaActions";
22import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent";
323
424export interface ExpandedImageItem {
525 src: string;
626 name: string;
727 type?: "video";
28 autoPlay?: boolean;
29 /** Authored remote destination to open when embedding fails, never a generated asset URL. */
30 originalUrl?: string;
31 actionsSource?: MediaActionSource;
832}
933
1034export interface ExpandedImagePreview {
......@@ -12,23 +36,98 @@ export interface ExpandedImagePreview {
1236 index: number;
1337}
1438
39/** Resolves a chat media reference on its owning environment, without downloading its bytes. */
40export async function resolveMarkdownMediaPreview(input: {
41 source: string;
42 resolvedFilePath?: string | undefined;
43 cwd?: string | undefined;
44 threadRef?: ScopedThreadRef | undefined;
45 httpBaseUrl?: string | undefined;
46 onOpenFile?: ((relativePath: string) => void) | undefined;
47 createAssetUrl: (input: {
48 environmentId: EnvironmentId;
49 input: { resource: AssetResource };
50 }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>;
51}): Promise<ExpandedImagePreview | null> {
52 const source =
53 input.resolvedFilePath === undefined
54 ? classifyMarkdownImageSource(input.source, input.cwd)
55 : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath };
56 if (source._tag === "Blocked") return null;
57
58 const path =
59 source._tag === "Direct"
60 ? source.uri.split(/[?#]/, 1)[0]!
61 : source.path.replace(/:\d+(?::\d+)?$/, "");
62 const name = path.split(/[\\/]/).at(-1) ?? "";
63 const extensionIndex = name.lastIndexOf(".");
64 const fileMimeType =
65 extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex));
66 const kind =
67 source._tag === "Direct"
68 ? mediaKindFromPath(source.uri)
69 : fileMimeType === null
70 ? null
71 : fileMimeType.startsWith("video/")
72 ? "video"
73 : "image";
74 if (kind === null) return null;
75
76 const reference =
77 source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd);
78 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
79 let src: string;
80 let asset: MediaActionSource["asset"];
81 if (source._tag === "Direct") {
82 src = resolveProtocolRelativeMediaUrl(source.uri);
83 } else {
84 if (!input.threadRef || !input.httpBaseUrl) {
85 throw new Error("Reconnect to this environment and open the media again.");
86 }
87 asset = {
88 environmentId: input.threadRef.environmentId,
89 resource: { _tag: "media-file", threadId: input.threadRef.threadId, path },
90 };
91 const result = await input.createAssetUrl({
92 environmentId: asset.environmentId,
93 input: { resource: asset.resource },
94 });
95 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
96 const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
97 if (assetUrl === null) throw new Error("The environment returned an invalid media URL.");
98 src = assetUrl + markdownImageSourceFragment(input.source);
99 }
100 return {
101 images: [
102 {
103 src,
104 name: name || kind,
105 ...(kind === "video" ? { type: "video", autoPlay: false } : {}),
106 ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null
107 ? { originalUrl: source.uri }
108 : {}),
109 actionsSource: {
110 kind,
111 name: name || kind,
112 src,
113 ...(reference ? { reference } : {}),
114 ...(asset ? { asset } : {}),
115 ...(relativePath && input.onOpenFile
116 ? { onOpenFile: () => input.onOpenFile?.(relativePath) }
117 : {}),
118 },
119 },
120 ],
121 index: 0,
122 };
123}
124
15125export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {
16126 const url = URL.createObjectURL(file);
17127 video.src = url;
18128 return () => URL.revokeObjectURL(url);
19129}
20130
21export async function downloadVideoPreview(src: string, name: string): Promise<void> {
22 const response = await fetch(src);
23 if (!response.ok) throw new Error(`Could not download video (${response.status}).`);
24 const url = URL.createObjectURL(await response.blob());
25 const anchor = document.createElement("a");
26 anchor.href = url;
27 anchor.download = name;
28 anchor.click();
29 setTimeout(() => URL.revokeObjectURL(url), 30_000);
30}
31
32131export function buildExpandedImagePreview(
33132 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,
34133 selectedImageId: string,
apps/web/src/components/chat/MessagesTimeline.tsx+1
......@@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25562556 resource={viewedImage.resource}
25572557 alt={viewedImage.alt}
25582558 srcFragment={viewedImage.srcFragment}
2559 workspaceRoot={workspaceRoot}
25592560 style={{ maxHeight: "16rem" }}
25602561 onImageExpand={onImageExpand}
25612562 />
apps/web/src/components/chat/externalLinkContextMenu.test.ts+2
......@@ -181,6 +181,8 @@ describe("external chat link context menu", () => {
181181 it.each([
182182 ["https://example.com", "example.com"],
183183 ["http://localhost:3000/path", "localhost"],
184 ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"],
185 ["//", null],
184186 ["#details", null],
185187 ["mailto:hello@example.com", null],
186188 ["file:///tmp/example.txt", null],
apps/web/src/components/chat/externalLinkContextMenu.ts+1-1
......@@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions {
7676export function resolveExternalWebLinkHost(href: string | undefined): string | null {
7777 if (!href) return null;
7878 try {
79 const url = new URL(href);
79 const url = new URL(href.startsWith("//") ? `https:${href}` : href);
8080 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
8181 return url.hostname || null;
8282 } catch {
apps/web/src/components/files/FilePreviewPanel.tsx+115-23
......@@ -4,7 +4,10 @@ import type {
44 ResolvedKeybindingsConfig,
55 ScopedThreadRef,
66} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
7import {
8 isWorkspaceImagePreviewPath,
9 isWorkspaceVideoPreviewPath,
10} from "@t3tools/shared/filePreview";
811import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
912import { Editor } from "@pierre/diffs/editor";
1013import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
......@@ -12,13 +15,16 @@ import {
1215 isAtomCommandInterrupted,
1316 squashAtomCommandFailure,
1417} from "@t3tools/client-runtime/state/runtime";
18import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1519import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
1620import * as Schema from "effect/Schema";
1721import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1822
1923import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20import { useAssetUrlState } from "~/assets/assetUrls";
24import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
2125import { OpenInPicker } from "~/components/chat/OpenInPicker";
26import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
27import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
2228import { useRemoteOpenState } from "~/remoteOpen";
2329import { useClientSettings } from "~/hooks/useSettings";
2430import { useTheme } from "~/hooks/useTheme";
......@@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: {
135141 readonly environmentId: EnvironmentId;
136142 readonly threadRef: ScopedThreadRef;
137143 readonly absolutePath: string;
144 readonly workspaceRoot: string;
138145 readonly alt: string;
139146 readonly workspaceMutationId: string | null;
140147}) {
141 const assetUrl = useAssetUrlState(props.environmentId, {
142 _tag: "workspace-file",
143 threadId: props.threadRef.threadId,
144 path: props.absolutePath,
145 });
148 const resource = useMemo(
149 () => ({
150 _tag: "workspace-file" as const,
151 threadId: props.threadRef.threadId,
152 path: props.absolutePath,
153 }),
154 [props.threadRef.threadId, props.absolutePath],
155 );
156 const assetUrl = useAssetUrlState(props.environmentId, resource);
146157 const [failedUrl, setFailedUrl] = useState<string | null>(null);
147158 const revisionSuffix =
148159 props.workspaceMutationId === null
149160 ? ""
150161 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
151162 const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
163 const actionsSource: MediaActionSource = {
164 kind: "image",
165 name: props.alt,
166 src: imageUrl,
167 reference: mediaFileReference(props.absolutePath, props.workspaceRoot),
168 asset: { environmentId: props.environmentId, resource },
169 };
152170
153171 if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {
154172 return (
155 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
156 Unable to load workspace image.
157 </div>
173 <MediaActions source={actionsSource}>
174 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
175 Unable to load workspace image.
176 </div>
177 </MediaActions>
158178 );
159179 }
160180
161181 return assetUrl._tag === "Success" && imageUrl !== null ? (
162182 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
163 <img
164 className="max-h-full max-w-full object-contain"
165 src={imageUrl}
166 alt={props.alt}
167 onError={() => setFailedUrl(imageUrl)}
168 />
183 <MediaActions source={actionsSource}>
184 <img
185 className="max-h-full max-w-full object-contain"
186 src={imageUrl}
187 alt={props.alt}
188 onError={() => setFailedUrl(imageUrl)}
189 />
190 </MediaActions>
169191 </div>
170192 ) : (
171193 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
......@@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: {
174196 );
175197}
176198
199function WorkspaceVideoPreview(props: {
200 readonly environmentId: EnvironmentId;
201 readonly threadRef: ScopedThreadRef;
202 readonly absolutePath: string;
203 readonly workspaceRoot: string;
204 readonly name: string;
205 readonly workspaceMutationId: string | null;
206}) {
207 const resource = useMemo(
208 () => ({
209 _tag: "media-file" as const,
210 threadId: props.threadRef.threadId,
211 path: props.absolutePath,
212 }),
213 [props.threadRef.threadId, props.absolutePath],
214 );
215 const assetUrl = useAssetUrlState(props.environmentId, resource);
216 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
217 useWorkspaceMutationRefresh({
218 mutationId: props.workspaceMutationId,
219 resourceKey: JSON.stringify([props.environmentId, resource]),
220 refresh: () => {
221 // Failed refreshes flow through assetUrl and can be retried from the player.
222 void refreshAssetUrl().catch(() => undefined);
223 },
224 });
225 const revisionSuffix =
226 props.workspaceMutationId === null
227 ? ""
228 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
229 const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
230
231 return (
232 <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4">
233 <MediaVideoPlayer
234 src={latestUrl}
235 sourceFailed={assetUrl._tag === "Failure"}
236 label={props.name}
237 revision={props.workspaceMutationId}
238 preload="metadata"
239 className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center"
240 onRetry={refreshAssetUrl}
241 actionsSource={{
242 kind: "video",
243 name: props.name,
244 src: latestUrl,
245 reference: mediaFileReference(props.absolutePath, props.workspaceRoot),
246 asset: { environmentId: props.environmentId, resource },
247 }}
248 />
249 </div>
250 );
251}
252
177253function clampFileLine(contents: string, requestedLine: number): number {
178254 let lineCount = 1;
179255 for (let index = 0; index < contents.length; index += 1) {
......@@ -791,8 +867,10 @@ export default function FilePreviewPanel({
791867 const openPreview = useAtomCommand(previewEnvironment.open, {
792868 reportFailure: false,
793869 });
794 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
795 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
870 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
871 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
872 const isMedia = isImage || isVideo;
873 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
796874 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
797875 // Reading markdown rendered is a preference, not a property of one file. Keeping
798876 // it on the panel meant a thread switch dropped it and forced source back.
......@@ -816,7 +894,10 @@ export default function FilePreviewPanel({
816894 (revealLine === null ||
817895 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId));
818896 const canOpenInBrowser =
819 relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);
897 relativePath !== null &&
898 !isVideo &&
899 isPreviewSupportedInRuntime() &&
900 isBrowserPreviewFile(relativePath);
820901 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
821902 const breadcrumbs = useMemo(
822903 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
......@@ -824,7 +905,7 @@ export default function FilePreviewPanel({
824905 );
825906 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
826907 useWorkspaceMutationRefresh({
827 enabled: relativePath !== null && !isImage && !selectedFilePending,
908 enabled: relativePath !== null && !isMedia && !selectedFilePending,
828909 mutationId: workspaceMutationId,
829910 refresh: file.refresh,
830911 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
......@@ -999,7 +1080,7 @@ export default function FilePreviewPanel({
9991080 </Tooltip>
10001081 </div>
10011082 ) : null}
1002 {relativePath && file.data?.truncated ? (
1083 {relativePath && !isMedia && file.data?.truncated ? (
10031084 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">
10041085 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
10051086 </div>
......@@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({
10111092 relativePath ? "flex" : "hidden",
10121093 )}
10131094 >
1014 {relativePath && isImage && absolutePath ? (
1095 {relativePath && isVideo && absolutePath ? (
1096 <WorkspaceVideoPreview
1097 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1098 environmentId={environmentId}
1099 threadRef={threadRef}
1100 absolutePath={absolutePath}
1101 workspaceRoot={cwd}
1102 name={relativePath}
1103 workspaceMutationId={workspaceMutationId}
1104 />
1105 ) : relativePath && isImage && absolutePath ? (
10151106 <WorkspaceImagePreview
10161107 key={absolutePath}
10171108 environmentId={environmentId}
10181109 threadRef={threadRef}
10191110 absolutePath={absolutePath}
1111 workspaceRoot={cwd}
10201112 alt={relativePath}
10211113 workspaceMutationId={workspaceMutationId}
10221114 />
......@@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({
10991191 selectedPathRevealId={revealRequestId}
11001192 onOpenFile={onOpenFile}
11011193 workspaceMutationId={workspaceMutationId}
1102 {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}
1194 {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})}
11031195 />
11041196 </aside>
11051197 ) : null}
apps/web/src/components/files/projectFilesQueryState.ts+11-3
......@@ -4,6 +4,10 @@ import type {
44 ProjectListEntriesResult,
55 ProjectReadFileResult,
66} from "@t3tools/contracts";
7import {
8 isWorkspaceImagePreviewPath,
9 isWorkspaceVideoPreviewPath,
10} from "@t3tools/shared/filePreview";
711import * as Cause from "effect/Cause";
812import * as Option from "effect/Option";
913import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -178,9 +182,13 @@ export function useProjectFileQuery(
178182 relativePath: string | null,
179183 enabled = true,
180184): ProjectQueryState<ProjectReadFileResult> {
181 const atom = enabled
182 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
183 : EMPTY_PROJECT_FILE_QUERY_ATOM;
185 const isMedia =
186 relativePath !== null &&
187 (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath));
188 const atom =
189 enabled && !isMedia
190 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
191 : EMPTY_PROJECT_FILE_QUERY_ATOM;
184192 const result = useAtomValue(atom);
185193 const refreshAtom = useAtomRefresh(atom);
186194 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
apps/web/src/components/media/MediaActions.tsx created+189
......@@ -0,0 +1,189 @@
1import {
2 mediaReferenceFileName,
3 type MediaReference,
4} from "@t3tools/client-runtime/media-reference";
5import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts";
8import { useCallback, useRef, useState, type ReactElement } from "react";
9
10import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
11import { readLocalApi } from "../../localApi";
12import { assetEnvironment } from "../../state/assets";
13import { readPreparedConnection } from "../../state/session";
14import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
15import { stackedThreadToast, toastManager } from "../ui/toast";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { downloadMedia, readMediaPng } from "./mediaContent";
18
19export interface MediaActionSource {
20 readonly kind: "image" | "video";
21 readonly name: string;
22 readonly src: string | null;
23 readonly reference?: MediaReference;
24 readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource };
25 readonly onOpenFile?: () => void;
26}
27
28function mediaFileName(source: MediaActionSource): string {
29 return (
30 (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind
31 );
32}
33
34/** Explicit byte operations get fresh capabilities without replacing a player's active source. */
35export function useMediaActions(source: MediaActionSource) {
36 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
37 reportFailure: false,
38 refresh: true,
39 });
40 const actionUrl = useCallback(async () => {
41 if (!source.asset) {
42 if (!source.src) throw new Error("This media is unavailable. Try reopening the preview.");
43 return source.src;
44 }
45 const { environmentId, resource } = source.asset;
46 const connection = readPreparedConnection(environmentId);
47 if (!connection) throw new Error("Reconnect to this environment and try again.");
48 const result = await createAssetUrl({ environmentId, input: { resource } });
49 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
50 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
51 if (!url) throw new Error("The environment returned an invalid media URL.");
52 return url;
53 }, [source, createAssetUrl]);
54 const save = useCallback(async () => {
55 await downloadMedia(await actionUrl(), mediaFileName(source));
56 }, [actionUrl, source]);
57 const copyImage = useCallback(async () => {
58 if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
59 throw new Error(
60 "Image copying is unavailable. Use a secure browser connection or save the image.",
61 );
62 }
63 // Start the clipboard write in the user gesture; fetching/decoding may finish later.
64 await navigator.clipboard.write([
65 new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }),
66 ]);
67 }, [actionUrl]);
68 return { save, copyImage };
69}
70
71type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file";
72
73/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */
74export function MediaActions({
75 source,
76 children,
77}: {
78 source: MediaActionSource;
79 children: ReactElement;
80}) {
81 const { save, copyImage } = useMediaActions(source);
82 const [tooltipOpen, setTooltipOpen] = useState(false);
83 const menuOpen = useRef(false);
84 const reference = source.reference;
85 const hasActions =
86 source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined;
87 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
88
89 const showMenu = async (position: { x: number; y: number }) => {
90 const api = readLocalApi();
91 if (!api || menuOpen.current) return;
92 menuOpen.current = true;
93 setTooltipOpen(false);
94 let failureTitle = "Could not open media menu";
95 let progressToast: ReturnType<typeof toastManager.add> | undefined;
96 try {
97 const items: ContextMenuItem<MediaAction>[] = [];
98 if (reference?.kind === "file") {
99 items.push({ id: "copy-full", label: "Copy full path" });
100 if (reference.relativePath)
101 items.push({ id: "copy-relative", label: "Copy relative path" });
102 } else if (reference?.kind === "url") {
103 items.push({ id: "copy-url", label: "Copy URL" });
104 }
105 if (source.kind === "image") {
106 const unavailable = source.src === null && source.asset === undefined;
107 items.push({ id: "save", label: "Save image", disabled: unavailable });
108 items.push({ id: "copy-image", label: "Copy image", disabled: unavailable });
109 }
110 if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" });
111
112 const action = await api.contextMenu.show(items, position);
113 if (!action) return;
114 failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`;
115 const text =
116 action === "copy-full" && reference?.kind === "file"
117 ? reference.path
118 : action === "copy-relative" && reference?.kind === "file"
119 ? reference.relativePath
120 : action === "copy-url" && reference?.kind === "url"
121 ? reference.url
122 : undefined;
123 if (text !== undefined) {
124 await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL");
125 toastManager.add({
126 type: "success",
127 title: action === "copy-url" ? "URL copied" : "Path copied",
128 });
129 } else if (action === "open-file") {
130 source.onOpenFile?.();
131 } else if (action === "save" || action === "copy-image") {
132 progressToast = toastManager.add({
133 type: "loading",
134 title: action === "save" ? "Preparing image download…" : "Copying image…",
135 });
136 await (action === "save" ? save() : copyImage());
137 toastManager.update(progressToast, {
138 type: "success",
139 title: action === "save" ? "Download started" : "Image copied",
140 });
141 }
142 } catch (error) {
143 const toast = stackedThreadToast({
144 type: "error",
145 title: failureTitle,
146 description: error instanceof Error ? error.message : "The media action failed.",
147 });
148 if (progressToast) toastManager.update(progressToast, toast);
149 else toastManager.add(toast);
150 } finally {
151 menuOpen.current = false;
152 }
153 };
154
155 return (
156 <Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
157 <TooltipTrigger
158 render={children}
159 tabIndex={0}
160 onContextMenu={(event) => {
161 if (!hasActions || event.defaultPrevented) return;
162 event.preventDefault();
163 event.stopPropagation();
164 const bounds = event.currentTarget.getBoundingClientRect();
165 void showMenu(
166 event.clientX === 0 && event.clientY === 0
167 ? { x: bounds.left, y: bounds.bottom }
168 : { x: event.clientX, y: event.clientY },
169 );
170 }}
171 onKeyDown={(event) => {
172 if (
173 !hasActions ||
174 event.defaultPrevented ||
175 !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))
176 )
177 return;
178 event.preventDefault();
179 event.stopPropagation();
180 const bounds = event.currentTarget.getBoundingClientRect();
181 void showMenu({ x: bounds.left, y: bounds.bottom });
182 }}
183 />
184 <TooltipPopup className="max-w-[min(40rem,calc(100vw-2rem))] break-all font-mono text-[11px] leading-tight">
185 {tooltip}
186 </TooltipPopup>
187 </Tooltip>
188 );
189}
apps/web/src/components/media/MediaVideoPlayer.tsx created+197
......@@ -0,0 +1,197 @@
1import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
3
4import { cn } from "../../lib/utils";
5import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
6import { Button } from "../ui/button";
7import { OpenMediaLink } from "./OpenMediaLink";
8import { MediaActions, type MediaActionSource } from "./MediaActions";
9
10interface MediaVideoPlayerProps {
11 readonly src: string | null;
12 readonly label: string;
13 readonly sourceFailed?: boolean | undefined;
14 readonly originalUrl?: string | undefined;
15 readonly revision?: string | null | undefined;
16 readonly preload?: "visible" | "metadata" | undefined;
17 readonly className?: string | undefined;
18 readonly videoClassName?: string | undefined;
19 readonly style?: CSSProperties | undefined;
20 readonly copyMarkdown?: string | undefined;
21 readonly onExpand?: ((src: string) => void) | undefined;
22 readonly onRetry?: (() => Promise<void>) | undefined;
23 readonly actionsSource?: MediaActionSource | undefined;
24}
25
26/** Keeps native range streaming and playback state consistent across inline and file previews. */
27export function MediaVideoPlayer({
28 src: latestSrc,
29 label,
30 sourceFailed = false,
31 originalUrl,
32 revision = null,
33 preload = "visible",
34 className,
35 videoClassName,
36 style,
37 copyMarkdown,
38 onExpand,
39 onRetry,
40 actionsSource,
41}: MediaVideoPlayerProps) {
42 const videoRef = useRef<HTMLVideoElement>(null);
43 const [playbackSource, setPlaybackSource] = useState<{
44 src: string;
45 revision: string | null;
46 } | null>(null);
47 const [failedSrc, setFailedSrc] = useState<string | null>(null);
48 const [retrying, setRetrying] = useState(false);
49 const [loadAttempt, setLoadAttempt] = useState(0);
50 const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null);
51 const src = playbackSource?.src ?? latestSrc;
52 const sourceRevision = playbackSource === null ? revision : playbackSource.revision;
53 const failed = src !== null ? failedSrc === src : sourceFailed;
54
55 // Re-signing must not reset the playhead. Changed files refresh once playback pauses.
56 const refreshPausedRevision = useCallback(() => {
57 const video = videoRef.current;
58 if (video === null || video.paused || video.ended) {
59 setPlaybackSource((current) =>
60 current !== null && current.revision !== revision ? null : current,
61 );
62 }
63 }, [revision]);
64 useEffect(refreshPausedRevision, [refreshPausedRevision]);
65
66 useEffect(() => {
67 const video = videoRef.current;
68 if (!video || preload === "metadata" || preloadedSrc === src) return;
69 if (typeof IntersectionObserver === "undefined") {
70 setPreloadedSrc(src);
71 return;
72 }
73 let active = true;
74 const observer = new IntersectionObserver(
75 (entries) => {
76 if (!active || !entries.some((entry) => entry.isIntersecting)) return;
77 setPreloadedSrc(src);
78 observer.disconnect();
79 },
80 { rootMargin: "200px" },
81 );
82 observer.observe(video);
83 return () => {
84 active = false;
85 observer.disconnect();
86 };
87 }, [src, preload, preloadedSrc, failed, loadAttempt]);
88
89 useEffect(() => {
90 const video = videoRef.current;
91 if (!video) return;
92 const pauseWhenHidden = () => {
93 if (document.hidden) video.pause();
94 };
95 document.addEventListener("visibilitychange", pauseWhenHidden);
96 return () => {
97 document.removeEventListener("visibilitychange", pauseWhenHidden);
98 video.pause();
99 };
100 }, [src, failed, loadAttempt]);
101
102 const retry = async () => {
103 if (retrying) return;
104 setRetrying(true);
105 try {
106 await onRetry?.();
107 setPlaybackSource(null);
108 setFailedSrc(null);
109 setLoadAttempt((current) => current + 1);
110 } catch {
111 setFailedSrc(src);
112 } finally {
113 setRetrying(false);
114 }
115 };
116
117 const expandButton =
118 onExpand && src !== null ? (
119 <Button
120 type="button"
121 variant="secondary"
122 size="icon-xs"
123 className={failed ? undefined : "absolute right-2 top-2"}
124 aria-label={`Expand ${label || "video"}`}
125 onClick={() => {
126 videoRef.current?.pause();
127 onExpand(latestSrc ?? src);
128 }}
129 >
130 <Maximize2Icon />
131 </Button>
132 ) : null;
133
134 const player = (
135 <span
136 className={cn("relative inline-block align-middle", className)}
137 style={style}
138 data-markdown-copy={copyMarkdown}
139 >
140 {failed ? (
141 <span
142 role="alert"
143 className="flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground"
144 >
145 <span className="inline-flex items-center gap-1.5">
146 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
147 Video unavailable{label ? ` · ${label}` : ""}
148 </span>
149 <span className="flex flex-wrap items-center justify-center gap-2">
150 {latestSrc !== null || onRetry ? (
151 <Button
152 size="sm"
153 variant="secondary"
154 disabled={retrying}
155 onClick={() => void retry()}
156 >
157 <RotateCwIcon />
158 {retrying ? "Retrying…" : "Retry video"}
159 </Button>
160 ) : null}
161 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />
162 {expandButton}
163 </span>
164 </span>
165 ) : src !== null ? (
166 <video
167 key={loadAttempt}
168 ref={videoRef}
169 src={src}
170 aria-label={label || "Video preview"}
171 controls
172 playsInline
173 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}
174 className={cn("aspect-video max-h-full w-full bg-black object-contain", videoClassName)}
175 style={style}
176 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
177 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}
178 onPause={refreshPausedRevision}
179 onEnded={refreshPausedRevision}
180 onError={() => {
181 if (latestSrc !== null && src !== latestSrc) setPlaybackSource(null);
182 else setFailedSrc(src);
183 }}
184 />
185 ) : (
186 <span
187 role="status"
188 aria-label="Loading video"
189 className="block aspect-video w-full rounded-lg bg-muted/60"
190 style={style}
191 />
192 )}
193 {!failed && expandButton}
194 </span>
195 );
196 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;
197}
apps/web/src/components/media/OpenMediaLink.tsx created+45
......@@ -0,0 +1,45 @@
1import { DownloadIcon, ExternalLinkIcon } from "lucide-react";
2
3import { resolveExternalWebLinkHost } from "../chat/externalLinkContextMenu";
4import { Button } from "../ui/button";
5import { resolveProtocolRelativeMediaUrl } from "./mediaContent";
6
7/** Navigates directly so the browser handles video playback and downloads, without fetching bytes. */
8export function OpenMediaLink(props: {
9 readonly originalUrl?: string | undefined;
10 readonly src?: string | null | undefined;
11 readonly fileName?: string | undefined;
12 readonly className?: string | undefined;
13}) {
14 const originalUrl =
15 resolveExternalWebLinkHost(props.originalUrl) !== null ? props.originalUrl : undefined;
16 const source = originalUrl ?? props.src;
17 if (!source) return null;
18 const url = resolveProtocolRelativeMediaUrl(source);
19 let isBlob = false;
20 try {
21 const protocol = new URL(url).protocol;
22 if (protocol !== "http:" && protocol !== "https:" && protocol !== "blob:") return null;
23 isBlob = protocol === "blob:";
24 } catch {
25 return null;
26 }
27 return (
28 <Button
29 size="sm"
30 variant="secondary"
31 className={props.className}
32 render={
33 <a
34 href={url}
35 target={isBlob ? undefined : "_blank"}
36 download={isBlob ? props.fileName || true : undefined}
37 rel="noopener noreferrer"
38 />
39 }
40 >
41 {isBlob ? <DownloadIcon /> : <ExternalLinkIcon />}
42 {originalUrl ? "Open original" : isBlob ? "Download video" : "Open in browser"}
43 </Button>
44 );
45}
apps/web/src/components/media/mediaContent.ts created+82
......@@ -0,0 +1,82 @@
1/** Resolves web references without inheriting the desktop renderer's custom app scheme. */
2export function resolveProtocolRelativeMediaUrl(src: string): string {
3 if (!src.startsWith("//")) return src;
4 const protocol =
5 typeof window !== "undefined" && window.location.protocol === "http:" ? "http:" : "https:";
6 return `${protocol}${src}`;
7}
8
9/** Reads media only for an explicit save/copy action; remote hosts must allow browser CORS. */
10async function readMediaBlob(src: string): Promise<Blob> {
11 let response: Response;
12 try {
13 response = await fetch(src);
14 } catch (cause) {
15 throw new Error(
16 "The file could not be fetched. The host may block browser access (CORS), or the connection may be unavailable.",
17 { cause },
18 );
19 }
20 if (!response.ok) throw new Error(`The file could not be fetched (HTTP ${response.status}).`);
21 const blob = await response.blob();
22 if (blob.type.split(";", 1)[0] === "text/html") {
23 throw new Error("This link returned a web page instead of media. Open the original URL.");
24 }
25 return blob;
26}
27
28/** Downloads the original bytes with their original filename, without changing playback URLs. */
29export async function downloadMedia(src: string, name: string): Promise<void> {
30 const url = URL.createObjectURL(await readMediaBlob(src));
31 try {
32 const anchor = document.createElement("a");
33 anchor.href = url;
34 anchor.download = name;
35 anchor.click();
36 } finally {
37 setTimeout(() => URL.revokeObjectURL(url), 30_000);
38 }
39}
40
41/** Converts browser-decodable images, including SVG, into the clipboard's portable PNG format. */
42export async function readMediaPng(src: string): Promise<Blob> {
43 const blob = await readMediaBlob(src);
44 if (blob.type.split(";", 1)[0] === "image/png") return blob;
45
46 const url = URL.createObjectURL(blob);
47 const image = new Image();
48 image.src = url;
49 try {
50 try {
51 await image.decode();
52 } catch (cause) {
53 throw new Error(
54 "The browser could not decode this image for copying. Try saving it instead.",
55 {
56 cause,
57 },
58 );
59 }
60 const { naturalWidth: width, naturalHeight: height } = image;
61 if (width <= 0 || height <= 0 || width * height > 64_000_000) {
62 throw new Error(
63 "This image is too large or has no usable dimensions. Try saving it instead.",
64 );
65 }
66 const canvas = document.createElement("canvas");
67 canvas.width = width;
68 canvas.height = height;
69 const context = canvas.getContext("2d");
70 if (!context) throw new Error("Image copying is unavailable in this browser.");
71 context.drawImage(image, 0, 0);
72 return await new Promise<Blob>((resolve, reject) => {
73 canvas.toBlob(
74 (png) =>
75 png ? resolve(png) : reject(new Error("The image could not be converted to PNG.")),
76 "image/png",
77 );
78 });
79 } finally {
80 URL.revokeObjectURL(url);
81 }
82}
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+2-4
......@@ -14,10 +14,8 @@ export const PullRequestMarkdownContext = createContext<string | null>(null);
1414 * A pull request body, rendered with the app's markdown renderer plus a card for each upload
1515 * embedded in it, which that renderer drops on the floor.
1616 *
17 * The card links out instead of playing in place. GitHub serves the file as uploaded — Mac
18 * recordings are `video/quicktime`, which no Chromium decodes — and the desktop window's
19 * `media-src` allows only `'self'`, the app scheme and `blob:`, so a remote source is refused
20 * before a byte is fetched. Opening the host works for every format.
17 * These upload URLs do not identify the media format. The card links to GitHub, where the
18 * original upload can be opened or downloaded even when its codec cannot play in the client.
2119 */
2220export function PullRequestMarkdown({
2321 text,
apps/web/src/contextMenuFallback.ts+4
......@@ -196,6 +196,10 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H
196196// dismiss it with the same result as an outside click or Escape.
197197let activeContextMenuDismiss: (() => void) | null = null;
198198
199export function isContextMenuOpen(): boolean {
200 return activeContextMenuDismiss !== null;
201}
202
199203/**
200204 * Closes the currently open fallback context menu, resolving its show() with
201205 * null (the same result as dismissing by outside click or Escape). No-op when
apps/web/src/lib/videoFirstFrame.test.ts created+83
......@@ -0,0 +1,83 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { prepareVideoFirstFrame } from "./videoFirstFrame";
4
5type PreviewVideo = Parameters<typeof prepareVideoFirstFrame>[0];
6
7function previewVideo(overrides: Partial<PreviewVideo> = {}): PreviewVideo {
8 return {
9 autoplay: false,
10 paused: true,
11 seeking: false,
12 currentTime: 0,
13 duration: 5,
14 played: { length: 0, start: () => 0, end: () => 0 },
15 src: "https://environment.test/api/assets/signed/video.mp4?signature=example",
16 ...overrides,
17 };
18}
19
20describe("prepareVideoFirstFrame", () => {
21 it.each([
22 [5, 0.1],
23 [0.05, 0.025],
24 ])(
25 "seeks within a %s second video only once when metadata repeats",
26 (duration, expectedPosition) => {
27 const video = previewVideo({ duration });
28 const seeks: number[] = [];
29 Object.defineProperty(video, "currentTime", {
30 get: () => seeks.at(-1) ?? 0,
31 set: (value: number) => seeks.push(value),
32 });
33 prepareVideoFirstFrame(video);
34 prepareVideoFirstFrame(video);
35
36 expect(seeks).toEqual([expectedPosition]);
37 },
38 );
39
40 it.each<Partial<PreviewVideo>>([
41 { autoplay: true },
42 { paused: false },
43 { seeking: true },
44 { currentTime: 2 },
45 { played: { length: 1, start: () => 0, end: () => 2 } },
46 { duration: 0 },
47 { duration: Number.POSITIVE_INFINITY },
48 ])("does not seek over playback or unavailable metadata: %j", (state) => {
49 const video = previewVideo(state);
50 const position = video.currentTime;
51
52 prepareVideoFirstFrame(video);
53
54 expect(video.currentTime).toBe(position);
55 });
56
57 it.each([
58 ["video.mp4#t=0,4", 0],
59 ["video.mp4#xywh=0,0,100,100&%74=3", 0],
60 ["video%23t=3.mp4", 0.1],
61 ])(
62 "distinguishes temporal fragments from encoded filename hashes: %s",
63 (path, expectedPosition) => {
64 const video = previewVideo({ src: `https://environment.test/${path}` });
65
66 prepareVideoFirstFrame(video);
67
68 expect(video.currentTime).toBe(expectedPosition);
69 },
70 );
71
72 it("tolerates a browser rejecting the preview seek", () => {
73 const video = previewVideo();
74 Object.defineProperty(video, "currentTime", {
75 get: () => 0,
76 set: () => {
77 throw new Error("The stream is not seekable yet");
78 },
79 });
80
81 expect(() => prepareVideoFirstFrame(video)).not.toThrow();
82 });
83});
apps/web/src/lib/videoFirstFrame.ts created+28
......@@ -0,0 +1,28 @@
1/** Requests an initial frame without playing or replacing the video's streaming source. */
2export function prepareVideoFirstFrame(
3 video: Pick<
4 HTMLVideoElement,
5 "autoplay" | "currentTime" | "duration" | "paused" | "played" | "seeking" | "src"
6 >,
7): void {
8 if (
9 video.autoplay ||
10 !video.paused ||
11 video.seeking ||
12 video.currentTime !== 0 ||
13 video.played.length > 0 ||
14 !Number.isFinite(video.duration) ||
15 video.duration <= 0
16 ) {
17 return;
18 }
19
20 const fragment = video.src.split("#", 2)[1];
21 if (fragment && new URLSearchParams(fragment).has("t")) return;
22
23 try {
24 video.currentTime = Math.min(0.1, video.duration / 2);
25 } catch {
26 // A rejected preview seek must leave the native Play control usable.
27 }
28}
apps/web/src/markdown-links.test.ts+1
......@@ -163,6 +163,7 @@ describe("resolveMarkdownFileLinkTarget", () => {
163163
164164 it("ignores external urls", () => {
165165 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
166 expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull();
166167 });
167168
168169 it("does not double-decode file URLs", () => {
apps/web/src/markdown-links.ts+9-151
......@@ -1,3 +1,8 @@
1import {
2 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
4} from "@t3tools/client-runtime/markdown-links";
5
16import { formatWorkspaceRelativePath } from "./filePathDisplay";
27import {
38 isTerminalLinkActivation,
......@@ -190,7 +195,7 @@ export function resolveMarkdownFileLinkTarget(
190195): string | null {
191196 if (!href) return null;
192197 const rawHref = normalizeMarkdownLinkDestination(href);
193 if (rawHref.length === 0 || rawHref.startsWith("#")) return null;
198 if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null;
194199
195200 const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")
196201 ? parseFileUrlHref(rawHref)
......@@ -221,124 +226,6 @@ export function resolveMarkdownFileLinkTarget(
221226 return resolvePathLinkTarget(pathWithPosition, cwd);
222227}
223228
224const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
225const PATH_SEPARATOR_PATTERN = /[\\/]/;
226const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
227const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
228const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;
229// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so
230// extensionless linking is limited to conventional filenames.
231const EXTENSIONLESS_FILE_NAMES = new Set([
232 "Makefile",
233 "makefile",
234 "GNUmakefile",
235 "Dockerfile",
236 "Containerfile",
237 "Justfile",
238 "justfile",
239 "Rakefile",
240 "Gemfile",
241 "Procfile",
242 "Brewfile",
243 "Caddyfile",
244 "Vagrantfile",
245 "Jenkinsfile",
246 "Podfile",
247 "Fastfile",
248 "BUILD",
249 "WORKSPACE",
250 "LICENSE",
251 "LICENCE",
252 "COPYING",
253 "NOTICE",
254 "AUTHORS",
255 "CONTRIBUTORS",
256 "CHANGELOG",
257 "README",
258 "CODEOWNERS",
259]);
260const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
261// Allowlists, not full public-suffix detection: treating every dotted first
262// segment as a host would swallow real paths like `conf.d/x.conf` or
263// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`,
264// `ts`, `rs`, `in`, ...) are deliberately absent from both sets.
265const GENERIC_HOSTNAME_TLDS = new Set([
266 "com",
267 "net",
268 "org",
269 "io",
270 "dev",
271 "app",
272 "ai",
273 "co",
274 "edu",
275 "gov",
276 "mil",
277 "info",
278 "biz",
279 "xyz",
280 "me",
281 "tv",
282 "cc",
283 "gg",
284 "chat",
285 "cloud",
286 "site",
287 "online",
288 "tech",
289 "store",
290 "link",
291]);
292// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch,
293// `.es` ES modules), so they only count as host evidence when the candidate
294// lacks a :line suffix — an explicit line reference marks a file and wins.
295const COUNTRY_HOSTNAME_TLDS = new Set([
296 "uk",
297 "de",
298 "fr",
299 "nl",
300 "se",
301 "no",
302 "fi",
303 "dk",
304 "pl",
305 "ch",
306 "at",
307 "be",
308 "es",
309 "it",
310 "pt",
311 "eu",
312 "us",
313 "ca",
314 "au",
315 "nz",
316 "jp",
317 "kr",
318 "cn",
319 "br",
320 "ru",
321 "mx",
322 "ie",
323 "cz",
324 "tr",
325 "sg",
326 "hk",
327]);
328
329/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */
330function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
331 if (segment.startsWith(".")) return false;
332 const lowered = segment.toLowerCase();
333 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
334 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
335 const labels = lowered.split(".");
336 const lastLabel = labels[labels.length - 1];
337 if (labels.length < 2 || lastLabel === undefined) return false;
338 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
339 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
340}
341
342229/**
343230 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
344231 * `origin/main`) rather than deliberate link destinations, so auto-linking
......@@ -349,33 +236,8 @@ export function resolveInlineCodeFileLinkMeta(
349236 codeText: string,
350237 cwd?: string,
351238): MarkdownFileLinkMeta | null {
352 const trimmed = codeText.trim();
353 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
354
355 // Windows drive/UNC paths keep their backslashes; any other backslashes are
356 // relative Windows-style paths, which neither the shape checks nor the
357 // downstream resolver understand — normalize them to forward slashes.
358 const candidate =
359 WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed)
360 ? trimmed
361 : trimmed.replaceAll("\\", "/");
362
363 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
364 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
365
366 const hasExplicitPathShape =
367 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
368 candidate.startsWith("/") ||
369 WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||
370 WINDOWS_UNC_PATH_PATTERN.test(candidate);
371 if (!hasExplicitPathShape) {
372 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
373 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
374 if (looksLikeHostname(firstSegment, hasPosition)) return null;
375 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) {
376 return null;
377 }
378 }
239 const candidate = inlineCodeFilePathCandidate(codeText);
240 if (candidate === null) return null;
379241
380242 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
381243 if (resolved) return resolved;
......@@ -383,11 +245,7 @@ export function resolveInlineCodeFileLinkMeta(
383245 // `Makefile:12` — conventional extensionless names fail the generic
384246 // markdown-link candidate patterns, but here the :line suffix already
385247 // marked the span as a file reference.
386 if (
387 cwd &&
388 BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) &&
389 EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, ""))
390 ) {
248 if (cwd && isConventionalFilePosition(candidate)) {
391249 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
392250 }
393251 return null;
apps/web/src/pierre-icons.ts+11
......@@ -3,6 +3,7 @@ import {
33 getBuiltInSpriteSheet,
44 type FileTreeIcons,
55} from "@pierre/trees";
6import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video";
67
78export interface PierreIconResolution {
89 name: string;
......@@ -13,6 +14,13 @@ const PIERRE_ICON_SPRITE_ID = "t3code-pierre-file-icon-sprite";
1314
1415const T3_FILE_ICON_SPRITE = `
1516<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" aria-hidden="true">
17 <!-- Lucide Film icon, ISC license. -->
18 <symbol id="t3-file-icon-video" viewBox="0 0 24 24">
19 <g fill="none" stroke="#a631be" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
20 <rect width="18" height="18" x="3" y="3" rx="2" />
21 <path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4" />
22 </g>
23 </symbol>
1624 <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32">
1725 <path d="M2 2H30V30H2" fill="#c12127" />
1826 <path d="M7.25 7.25h17.5v17.5h-3.5v-14H16v14H7.25" fill="#fff" />
......@@ -50,6 +58,9 @@ export const T3_PIERRE_ICONS = {
5058 "pnpm-lock.yaml": "t3-file-icon-pnpm",
5159 "pnpm-workspace.yaml": "t3-file-icon-pnpm",
5260 },
61 byFileExtension: Object.fromEntries(
62 VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]),
63 ),
5364} satisfies FileTreeIcons;
5465
5566const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS);
apps/web/src/state/use-atom-query-runner.ts+5-3
......@@ -1,7 +1,7 @@
11import { RegistryContext } from "@effect/atom-react";
22import {
33 executeAtomQuery,
4 type AtomCommandOptions,
4 type AtomQueryOptions,
55 type AtomCommandResult,
66} from "@t3tools/client-runtime/state/runtime";
77import { AsyncResult, type Atom } from "effect/unstable/reactivity";
......@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";
99
1010export function useAtomQueryRunner<T, A, E>(
1111 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,
12 options?: string | AtomCommandOptions,
12 options?: string | AtomQueryOptions,
1313): (target: T) => Promise<AtomCommandResult<A, E>> {
1414 const registry = useContext(RegistryContext);
1515 const explicitLabel = typeof options === "string" ? options : options?.label;
1616 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);
1717 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);
18 const refresh = typeof options === "string" ? false : (options?.refresh ?? false);
1819
1920 return useCallback(
2021 (target: T) => {
......@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(
2324 label: explicitLabel ?? atom.label?.[0] ?? "atom query",
2425 reportFailure,
2526 reportDefect,
27 refresh,
2628 });
2729 },
28 [explicitLabel, family, registry, reportDefect, reportFailure],
30 [explicitLabel, family, registry, refresh, reportDefect, reportFailure],
2931 );
3032}
docs/internals/environment-auth.md+34
......@@ -28,6 +28,40 @@ managed relay connectivity:
2828The desktop bootstrap credential and command-line administrative bootstrap
2929credentials additionally grant `access:read access:write relay:write`.
3030
31## Media preview access
32
33Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for
34supported images and videos anywhere the environment's server account can read. A thread ID
35supplies the workspace for relative paths; absolute paths refer to the environment host, not the
36client. This follows the environment-wide authorization model rather than introducing per-project
37filesystem permissions.
38
39[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular
40file, and validates the resolved file's literal extension. It opens the file and signs its canonical
41path and device/inode identity for one hour. The token grants access to that exact file, not adjacent
42files or its containing directory. Serving rechecks the canonical path, media type, and opened
43descriptor's identity, then streams full or partial responses from that descriptor. Replacing a
44file atomically requires a freshly signed URL; editing it in place does not. The existing workspace
45boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their
46separate asset resource.
47
48Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment
49can fetch that file until it expires. Clients should copy the authored reference, not the temporary
50URL. Responses use `nosniff`; SVG responses retain their restrictive sandbox policy. Video reads
51support byte ranges so playback does not require a complete download first.
52
53Host videos can change in place, so their responses use `private, no-store` and omit `ETag`
54and `Last-Modified`. File metadata cannot prove byte-for-byte identity for `If-Range`; advertising
55those validators would encourage native players to send conditional seeks that require a full
56response. Ordinary range requests receive partial responses. An explicitly supplied `If-Range`
57still falls back to a full response because no strong validator is available. Host image previews
58keep their private cache policy and weak metadata validators.
59
60The server serves media in place without importing it into attachment storage. Deletion makes
61future server reads fail, though an already loaded client or its cache can retain bytes. Native
62viewers may use temporary client-side files for display or explicit sharing; those are not durable
63environment copies.
64
3165## Authentication Flows
3266
3367### Browser Session
docs/user/composer.md+43-1
......@@ -34,7 +34,7 @@ viewer and PDFs open the system chooser.
3434Select a video attachment before or after sending to play it. Web and desktop use the browser's
3535built-in controls. On mobile, videos open in a full-screen player with native playback controls.
3636Supported videos show a thumbnail in the conversation and composer.
37On iOS, received videos stream from their environment as they play. Supported formats and codecs
37On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs
3838depend on the browser or device; you can save an unsupported video to open it in another app.
3939
4040On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the
......@@ -52,6 +52,48 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
5252GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
5353when starting a thread or changing an existing thread's model.
5454
55## Images and videos in messages
56
57On web, desktop, and mobile, select a link to an image or video to open it inside T3 Code.
58Workspace image and video links open the file viewer. Links to media outside the workspace
59open a media preview.
60Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They
61stream from the environment as needed, rather than downloading the entire video before playback.
62Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline;
63video embeds show a player with controls and an option to expand. Visible video previews load
64an initial frame when supported, but stay paused until you press Play. Video file references use
65a filmstrip icon.
66
67On web and desktop, hover over a preview to see its full file path or original URL. Right-click
68to copy that reference, save an image, or copy an image to the clipboard. Use the video player's
69built-in controls to download videos. If the player cannot decode a video, its error message
70offers a link to open the source in the browser. Workspace media also offers **Copy relative
71path** and **Open in file viewer**. These actions are available in expanded previews too.
72
73On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its
74source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file
75viewer from the same menu. Saving downloads a copy only when you request it; it does not change
76how the video buffers during playback.
77
78Use Markdown image syntax to embed either kind of media:
79
80```markdown
81![Screenshot](/tmp/screenshot.png)
82![Recording](/tmp/recording.mp4)
83[Open recording](/tmp/recording.mp4)
84```
85
86Relative paths resolve from the thread's workspace. Absolute paths and `file://` links refer to
87the environment's machine, even when you connect remotely or use your phone. Supported media
88can live outside the workspace, including in Downloads or `/tmp`.
89
90T3 Code serves the original file without adding it to attachment storage. If that file is moved
91or deleted, its preview can no longer load from the environment. A browser or device may still
92have a cached copy. Supported video formats and codecs depend on the browser or device.
93
94Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags
95are not supported; use the Markdown embed syntax above.
96
5597## Changing projects
5698
5799On web and desktop, changing the project from a new thread keeps the current environment when that
packages/client-runtime/package.json+8
......@@ -19,6 +19,14 @@
1919 "types": "./src/markdownImages.ts",
2020 "default": "./src/markdownImages.ts"
2121 },
22 "./markdown-links": {
23 "types": "./src/markdownLinks.ts",
24 "default": "./src/markdownLinks.ts"
25 },
26 "./media-reference": {
27 "types": "./src/mediaReference.ts",
28 "default": "./src/mediaReference.ts"
29 },
2230 "./codex-file-citations": {
2331 "types": "./src/codexFileCitations.ts",
2432 "default": "./src/codexFileCitations.ts"
packages/client-runtime/src/markdownImages.ts+1-1
......@@ -67,7 +67,7 @@ function joinWorkspacePath(workspaceRoot: string, relativePath: string): string
6767}
6868
6969/**
70 * Classifies a markdown image source by where its bytes must be loaded from.
70 * Classifies a markdown image or video source by where its bytes must be loaded from.
7171 * Filesystem paths belong to the environment host and must never reach a
7272 * browser or native image component without first becoming a signed asset URL.
7373 */
packages/client-runtime/src/markdownLinks.test.ts created+30
......@@ -0,0 +1,30 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js";
4
5describe("inlineCodeFilePathCandidate", () => {
6 it.each([
7 ["src\\main.ts", "src/main.ts"],
8 ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"],
9 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
10 ["conf.d/nginx.conf", "conf.d/nginx.conf"],
11 ["script.pl:10", "script.pl:10"],
12 ["node.meta", null],
13 ["Recorded evidence here: /tmp/image.png", null],
14 ["origin/main", null],
15 ["127.0.0.1:3000", null],
16 ["example.com/index.html", null],
17 ["example.pl/index.html", null],
18 ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => {
19 expect(inlineCodeFilePathCandidate(source)).toBe(candidate);
20 });
21});
22
23describe("isConventionalFilePosition", () => {
24 it("distinguishes extensionless file locations from labels and ports", () => {
25 expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true);
26 expect(isConventionalFilePosition("Makefile")).toBe(false);
27 expect(isConventionalFilePosition("TODO:12")).toBe(false);
28 expect(isConventionalFilePosition("port:3000")).toBe(false);
29 });
30});
packages/client-runtime/src/markdownLinks.ts created+158
......@@ -0,0 +1,158 @@
1const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
2const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
3const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
4const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
5const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
6const PATH_SEPARATOR_PATTERN = /[\\/]/;
7const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
8const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
9const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;
10// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.
11const EXTENSIONLESS_FILE_NAMES = new Set([
12 "Makefile",
13 "makefile",
14 "GNUmakefile",
15 "Dockerfile",
16 "Containerfile",
17 "Justfile",
18 "justfile",
19 "Rakefile",
20 "Gemfile",
21 "Procfile",
22 "Brewfile",
23 "Caddyfile",
24 "Vagrantfile",
25 "Jenkinsfile",
26 "Podfile",
27 "Fastfile",
28 "BUILD",
29 "WORKSPACE",
30 "LICENSE",
31 "LICENCE",
32 "COPYING",
33 "NOTICE",
34 "AUTHORS",
35 "CONTRIBUTORS",
36 "CHANGELOG",
37 "README",
38 "CODEOWNERS",
39]);
40const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
41// These allowlists avoid classifying dotted directories such as `conf.d/`
42// or filenames such as `Makefile.in:12` as hosts.
43const GENERIC_HOSTNAME_TLDS = new Set([
44 "com",
45 "net",
46 "org",
47 "io",
48 "dev",
49 "app",
50 "ai",
51 "co",
52 "edu",
53 "gov",
54 "mil",
55 "info",
56 "biz",
57 "xyz",
58 "me",
59 "tv",
60 "cc",
61 "gg",
62 "chat",
63 "cloud",
64 "site",
65 "online",
66 "tech",
67 "store",
68 "link",
69]);
70// Country codes also name file extensions. A :line suffix makes `.pl`
71// and `.pt` files more likely than hostnames.
72const COUNTRY_HOSTNAME_TLDS = new Set([
73 "uk",
74 "de",
75 "fr",
76 "nl",
77 "se",
78 "no",
79 "fi",
80 "dk",
81 "pl",
82 "ch",
83 "at",
84 "be",
85 "es",
86 "it",
87 "pt",
88 "eu",
89 "us",
90 "ca",
91 "au",
92 "nz",
93 "jp",
94 "kr",
95 "cn",
96 "br",
97 "ru",
98 "mx",
99 "ie",
100 "cz",
101 "tr",
102 "sg",
103 "hk",
104]);
105
106function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
107 if (segment.startsWith(".")) return false;
108 const lowered = segment.toLowerCase();
109 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
110 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
111 const labels = lowered.split(".");
112 const lastLabel = labels.at(-1);
113 if (labels.length < 2 || lastLabel === undefined) return false;
114 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
115 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
116}
117
118/** Recognizes conventional extensionless filenames with an explicit line position. */
119export function isConventionalFilePosition(path: string): boolean {
120 return (
121 BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) &&
122 EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, ""))
123 );
124}
125
126/**
127 * Picks path-shaped inline code for the client's markdown file-link resolver.
128 * It does not resolve paths or turn plain prose and fenced code into links.
129 */
130export function inlineCodeFilePathCandidate(codeText: string): string | null {
131 const trimmed = codeText.trim();
132 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
133
134 const candidate =
135 WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed)
136 ? trimmed
137 : trimmed.replaceAll("\\", "/");
138 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
139 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
140
141 const hasExplicitPathShape =
142 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
143 candidate.startsWith("/") ||
144 WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||
145 WINDOWS_UNC_PATH_PATTERN.test(candidate);
146 if (!hasExplicitPathShape) {
147 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
148 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
149 if (looksLikeHostname(firstSegment, hasPosition)) return null;
150 const basename =
151 withoutPosition
152 .replace(/[/\\]+$/, "")
153 .split(/[\\/]/)
154 .at(-1) ?? "";
155 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null;
156 }
157 return candidate;
158}
packages/client-runtime/src/mediaReference.test.ts created+39
......@@ -0,0 +1,39 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { mediaFileReference, mediaReferenceFileName } from "./mediaReference.ts";
4
5describe("mediaFileReference", () => {
6 it.each([
7 ["/work/project/./media/../clip.mp4", "/work/project/", "clip.mp4"],
8 ["/work/project/../outside.mp4", "/work/project", undefined],
9 ["/work/project-other/clip.mp4", "/work/project", undefined],
10 ["/work/Project/clip.mp4", "/work/project", undefined],
11 ["/work/project/a\\b.mp4", "/work/project", "a\\b.mp4"],
12 ["/work/clip.mp4", "/work/other/..", "clip.mp4"],
13 ["/clip.mp4", "/", "clip.mp4"],
14 ["C:\\WORK\\project\\media\\..\\Clip.mp4", "c:/work/project/", "Clip.mp4"],
15 ["D:\\work\\clip.mp4", "C:\\work", undefined],
16 ["\\\\Server\\Share\\Project\\Clip.mp4", "//server/share/project", "Clip.mp4"],
17 ["\\\\server\\other\\clip.mp4", "\\\\server\\share", undefined],
18 ["../clip.mp4", "/work/project", undefined],
19 ])("preserves %s and only labels paths inside %s as relative", (path, root, relativePath) => {
20 expect(mediaFileReference(path, root)).toEqual({
21 kind: "file",
22 path,
23 ...(relativePath === undefined ? {} : { relativePath }),
24 });
25 });
26});
27
28describe("mediaReferenceFileName", () => {
29 it.each([
30 [{ kind: "file", path: "/tmp/take\\one%20.mp4" }, "take\\one%20.mp4"],
31 [{ kind: "file", path: "C:\\clips/take\\one.mp4" }, "one.mp4"],
32 [{ kind: "file", path: "\\\\server\\share\\one.mp4" }, "one.mp4"],
33 [{ kind: "url", url: "//cdn.example/clip%20one%2Emp4?sig=a+b#t=2" }, "clip one.mp4"],
34 [{ kind: "url", url: "https://cdn.example/clip%2520.mp4" }, "clip%20.mp4"],
35 [{ kind: "url", url: "https://cdn.example/clip%20%oops.mp4" }, "clip%20%oops.mp4"],
36 ] as const)("preserves filename semantics for %j", (reference, name) => {
37 expect(mediaReferenceFileName(reference)).toBe(name);
38 });
39});
packages/client-runtime/src/mediaReference.ts created+95
......@@ -0,0 +1,95 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3/** The authored media location, never the temporary URL used to load its bytes. */
4export type MediaReference =
5 | {
6 readonly kind: "file";
7 readonly path: string;
8 readonly relativePath?: string;
9 }
10 | { readonly kind: "url"; readonly url: string };
11
12function absolutePathParts(path: string) {
13 const windows = isWindowsAbsolutePath(path) || path.startsWith("//");
14 const normalized = windows ? path.replaceAll("\\", "/") : path;
15 const prefix = windows
16 ? /^(?:[a-z]:\/|\/\/[^/]+\/[^/]+(?:\/|$))/i.exec(normalized)?.[0]
17 : normalized.startsWith("/")
18 ? "/"
19 : undefined;
20 if (!prefix) return undefined;
21
22 const segments: string[] = [];
23 for (const segment of normalized.slice(prefix.length).split("/")) {
24 if (!segment || segment === ".") continue;
25 if (segment === "..") segments.pop();
26 else segments.push(segment);
27 }
28 const root = prefix.replace(/\/$/, "");
29 return { root: windows ? root.toLowerCase() : root, segments, windows };
30}
31
32/** Compares paths lexically for the copy menu; it does not resolve filesystem symlinks. */
33export function mediaFileReference(
34 path: string,
35 workspaceRoot?: string | null,
36): Extract<MediaReference, { kind: "file" }> {
37 const target = absolutePathParts(path);
38 const workspace = workspaceRoot ? absolutePathParts(workspaceRoot) : undefined;
39 if (
40 !target ||
41 !workspace ||
42 target.windows !== workspace.windows ||
43 target.root !== workspace.root ||
44 target.segments.length <= workspace.segments.length ||
45 !workspace.segments.every((segment, index) =>
46 workspace.windows
47 ? segment.toLowerCase() === target.segments[index]?.toLowerCase()
48 : segment === target.segments[index],
49 )
50 ) {
51 return { kind: "file", path };
52 }
53 return {
54 kind: "file",
55 path,
56 relativePath: target.segments.slice(workspace.segments.length).join("/"),
57 };
58}
59
60/** Pass the authored source, not a generated URL used by the media player. */
61export function mediaUrlReference(
62 url: string,
63): Extract<MediaReference, { kind: "url" }> | undefined {
64 if (!/^(?:https?:\/\/|\/\/)/i.test(url)) return undefined;
65 try {
66 const parsed = new URL(url.startsWith("//") ? `https:${url}` : url);
67 return parsed.protocol === "http:" || parsed.protocol === "https:"
68 ? { kind: "url", url }
69 : undefined;
70 } catch {
71 return undefined;
72 }
73}
74
75/** Local paths are already decoded; URL filename escapes are decoded exactly once. */
76export function mediaReferenceFileName(reference: MediaReference): string | undefined {
77 if (reference.kind === "file") {
78 const windows = isWindowsAbsolutePath(reference.path) || reference.path.startsWith("//");
79 return reference.path.split(windows ? /[\\/]/ : "/").at(-1) || undefined;
80 }
81
82 let basename: string | undefined;
83 try {
84 const url = reference.url;
85 basename = new URL(url.startsWith("//") ? `https:${url}` : url).pathname.split("/").at(-1);
86 } catch {
87 return undefined;
88 }
89 if (!basename) return undefined;
90 try {
91 return decodeURIComponent(basename);
92 } catch {
93 return basename;
94 }
95}
packages/client-runtime/src/work-log/presentation.test.ts+1-1
......@@ -58,7 +58,7 @@ describe("resolveViewedImageAsset", () => {
5858 }),
5959 ).toEqual({
6060 resource: {
61 _tag: "workspace-file",
61 _tag: "media-file",
6262 threadId,
6363 path: "/workspace/screens/logo.svg",
6464 },
packages/client-runtime/src/work-log/presentation.ts+2-2
......@@ -99,7 +99,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin
9999}
100100
101101export interface ViewedImageAsset {
102 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
102 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
103103 readonly alt: string;
104104 readonly srcFragment: string;
105105}
......@@ -129,7 +129,7 @@ export function resolveViewedImageAsset(
129129 return {
130130 resource: attachmentId
131131 ? { _tag: "attachment", attachmentId }
132 : { _tag: "workspace-file", threadId: input.threadId, path },
132 : { _tag: "media-file", threadId: input.threadId, path },
133133 alt: path.split(/[\\/]/).at(-1) ?? "image",
134134 srcFragment: markdownImageSourceFragment(source),
135135 };
packages/contracts/src/assets.ts+13-3
......@@ -15,6 +15,10 @@ export const AssetResource = Schema.Union([
1515 threadId: ThreadId,
1616 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
1717 }),
18 Schema.TaggedStruct("media-file", {
19 threadId: ThreadId,
20 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
21 }),
1822 Schema.TaggedStruct("attachment", {
1923 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
2024 /** Display name and mime from the `ChatAttachment` the caller holds. The
......@@ -152,7 +156,9 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass
152156 },
153157) {
154158 override get message(): string {
155 return "Only browser documents and images can be previewed.";
159 return this.resource._tag === "media-file"
160 ? "Only images and videos can be previewed."
161 : "Only browser documents and images can be previewed.";
156162 }
157163}
158164
......@@ -164,7 +170,9 @@ export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass<
164170 },
165171) {
166172 override get message(): string {
167 return "Failed to inspect the workspace asset.";
173 return this.resource._tag === "media-file"
174 ? "Failed to inspect the media file."
175 : "Failed to inspect the workspace asset.";
168176 }
169177}
170178
......@@ -175,7 +183,9 @@ export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<As
175183 },
176184) {
177185 override get message(): string {
178 return "Workspace asset was not found.";
186 return this.resource._tag === "media-file"
187 ? "Media file was not found."
188 : "Workspace asset was not found.";
179189 }
180190}
181191
packages/shared/src/filePreview.test.ts+27
......@@ -4,6 +4,8 @@ import {
44 isWorkspaceBrowserPreviewPath,
55 isWorkspaceImagePreviewPath,
66 isWorkspacePreviewEntryPath,
7 isWorkspaceVideoPreviewPath,
8 mediaKindFromPath,
79} from "./filePreview.ts";
810
911describe("workspace file previews", () => {
......@@ -34,3 +36,28 @@ describe("workspace file previews", () => {
3436 },
3537 );
3638});
39
40describe("media path parsing", () => {
41 it.each([
42 ["https://cdn.example/clip.webm?download=1#t=2", "video"],
43 ["https://example.com/download?name=recording.mp4", null],
44 ["https://example.png", null],
45 ["images%2Fresult%2Epng", "image"],
46 ["images/result%23v2.png", "image"],
47 ["images/result.png%23secret.txt", null],
48 ["images/result.png%3Fsecret.txt", null],
49 ["/tmp/100%.png", "image"],
50 ])("classifies the decoded pathname of %s", (source, kind) => {
51 expect(mediaKindFromPath(source)).toBe(kind);
52 });
53
54 it.each([
55 ["recording.mp4#t=2", "video", false],
56 ["recording%2Emp4", "video", false],
57 ["recording#take2.mp4", null, true],
58 ["recording?take2.mp4", null, true],
59 ])("distinguishes authored URLs from literal filenames in %s", (source, kind, literalVideo) => {
60 expect(mediaKindFromPath(source)).toBe(kind);
61 expect(isWorkspaceVideoPreviewPath(source)).toBe(literalVideo);
62 });
63});
packages/shared/src/filePreview.ts+58
......@@ -1,3 +1,5 @@
1import { videoMimeType } from "./video.ts";
2
13export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
24
35export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
......@@ -11,6 +13,57 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
1113 ".webp",
1214] as const;
1315
16const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
17 [".avif", "image/avif"],
18 [".gif", "image/gif"],
19 [".ico", "image/x-icon"],
20 [".jpeg", "image/jpeg"],
21 [".jpg", "image/jpeg"],
22 [".png", "image/png"],
23 [".svg", "image/svg+xml"],
24 [".webp", "image/webp"],
25]);
26
27/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
28export function mediaMimeTypeFromExtension(extension: string): string | null {
29 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
30 return (
31 IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
32 videoMimeType({ name: `media${extension}`, mimeType: "" })
33 );
34}
35
36/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
37export function mediaMimeType(path: string): string | null {
38 const trimmed = path.trim();
39 const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
40 const dataMimeType = /^data:((?:image|video)\/[\w.+-]+)[;,]/i.exec(source)?.[1];
41 if (dataMimeType) return dataMimeType.toLowerCase();
42
43 let sourcePath = source.split(/[?#]/, 1)[0] ?? "";
44 if (/^(?:https?:|file:|\/\/)/i.test(source)) {
45 try {
46 sourcePath = new URL(source, "https://media.invalid").pathname;
47 } catch {
48 return null;
49 }
50 }
51 try {
52 sourcePath = decodeURIComponent(sourcePath);
53 } catch {
54 // A literal percent character is valid in a filename.
55 }
56 const basename = sourcePath.split(/[\\/]/).at(-1) ?? "";
57 const extensionIndex = basename.lastIndexOf(".");
58 return extensionIndex < 0 ? null : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
59}
60
61export function mediaKindFromPath(path: string): "image" | "video" | null {
62 const mimeType = mediaMimeType(path);
63 if (mimeType === null) return null;
64 return mimeType.startsWith("video/") ? "video" : "image";
65}
66
1467function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
1568 const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
1669 return extensions.some((extension) => pathWithoutQuery.endsWith(extension));
......@@ -24,6 +77,11 @@ export function isWorkspaceImagePreviewPath(path: string): boolean {
2477 return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);
2578}
2679
80/** File viewers receive literal filesystem paths, not Markdown URLs. */
81export function isWorkspaceVideoPreviewPath(path: string): boolean {
82 return videoMimeType({ name: path, mimeType: "" }) !== null;
83}
84
2785export function isWorkspacePreviewEntryPath(path: string): boolean {
2886 return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
2987}
packages/shared/src/video.ts+2
......@@ -8,6 +8,8 @@ const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([
88 ["webm", "video/webm"],
99]);
1010
11export const VIDEO_FILE_EXTENSIONS = Object.freeze([...VIDEO_MIME_TYPE_BY_EXTENSION.keys()]);
12
1113/** Recognizes videos even when the file picker omitted their MIME type. */
1214export function videoMimeType(attachment: {
1315 readonly name: string;
pnpm-lock.yaml+6-2
......@@ -273,7 +273,7 @@ importers:
273273 version: link:../../packages/contracts
274274 '@t3tools/mobile-markdown-text':
275275 specifier: file:./modules/t3-markdown-text
276 version: file:apps/mobile/modules/t3-markdown-text(8819e1ff552fc19b333a2dfaf02d39db)
276 version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)
277277 '@t3tools/mobile-review-diff-native':
278278 specifier: file:./modules/t3-review-diff
279279 version: file:apps/mobile/modules/t3-review-diff
......@@ -4433,6 +4433,8 @@ packages:
44334433 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text':
44344434 resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory}
44354435 peerDependencies:
4436 '@t3tools/client-runtime': '*'
4437 '@t3tools/shared': '*'
44364438 expo-asset: '*'
44374439 expo-clipboard: '*'
44384440 expo-haptics: '*'
......@@ -14253,8 +14255,10 @@ snapshots:
1425314255 dependencies:
1425414256 defer-to-connect: 2.0.1
1425514257
14256 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(8819e1ff552fc19b333a2dfaf02d39db)':
14258 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)':
1425714259 dependencies:
14260 '@t3tools/client-runtime': link:packages/client-runtime
14261 '@t3tools/shared': link:packages/shared
1425814262 expo-asset: 57.0.15(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)(typescript@6.0.3)
1425914263 expo-clipboard: 57.0.1(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)
1426014264 expo-haptics: 57.0.2(expo@57.0.18)