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", () => {...@@ -225,7 +225,7 @@ describe("ElectronProtocol", () => {
225 "http:",225 "http:",
226 "https:",226 "https:",
227 ]);227 ]);
228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]);
229 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);229 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
230 });230 });
231});231});
apps/desktop/src/electron/ElectronProtocol.ts+3-1
...@@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat...@@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
87 `script-src ${scriptSources.join(" ")}`,87 `script-src ${scriptSources.join(" ")}`,
88 `connect-src ${connectSources.join(" ")}`,88 `connect-src ${connectSources.join(" ")}`,
89 `img-src 'self' ${input.scheme}: blob: data: http: https:`,89 `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:`,
91 "style-src 'self' 'unsafe-inline'",91 "style-src 'self' 'unsafe-inline'",
92 `font-src 'self' ${input.scheme}: data:`,92 `font-src 'self' ${input.scheme}: data:`,
93 "worker-src 'self' blob:",93 "worker-src 'self' blob:",
...@@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void {...@@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
118 secure: true,118 secure: true,
119 supportFetchAPI: true,119 supportFetchAPI: true,
120 corsEnabled: true,120 corsEnabled: true,
121 stream: true,
121 },122 },
122 },123 },
123 {124 {
...@@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void {...@@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
127 secure: true,128 secure: true,
128 supportFetchAPI: true,129 supportFetchAPI: true,
129 corsEnabled: true,130 corsEnabled: true,
131 stream: true,
130 },132 },
131 },133 },
132 ]);134 ]);
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 @@...@@ -26,6 +26,8 @@
26 "./types": "./src/SelectableMarkdownText.types.ts"26 "./types": "./src/SelectableMarkdownText.types.ts"
27 },27 },
28 "peerDependencies": {28 "peerDependencies": {
29 "@t3tools/client-runtime": "*",
30 "@t3tools/shared": "*",
29 "expo-asset": "*",31 "expo-asset": "*",
30 "expo-clipboard": "*",32 "expo-clipboard": "*",
31 "expo-haptics": "*",33 "expo-haptics": "*",
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs+2
...@@ -66,6 +66,7 @@ const colors = {...@@ -66,6 +66,7 @@ const colors = {
66 terraform: "#693acf",66 terraform: "#693acf",
67 text: "#84848a",67 text: "#84848a",
68 typescript: "#1a85d4",68 typescript: "#1a85d4",
69 video: "#a631be",
69 vite: "#a631be",70 vite: "#a631be",
70 vscode: "#1a85d4",71 vscode: "#1a85d4",
71 vue: "#199f43",72 vue: "#199f43",
...@@ -83,6 +84,7 @@ const customIcons = {...@@ -83,6 +84,7 @@ const customIcons = {
83 pnpm: "t3-file-icon-pnpm",84 pnpm: "t3-file-icon-pnpm",
84 readme: "t3-file-icon-readme",85 readme: "t3-file-icon-readme",
85 tsconfig: "t3-file-icon-tsconfig",86 tsconfig: "t3-file-icon-tsconfig",
87 video: "t3-file-icon-video",
86};88};
8789
88function symbolFromSprite(sprite, id) {90function symbolFromSprite(sprite, id) {
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts+1
...@@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = {...@@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
51 text: require("../assets/file-icons/pierre_text.png"),51 text: require("../assets/file-icons/pierre_text.png"),
52 tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),52 tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
53 typescript: require("../assets/file-icons/pierre_typescript.png"),53 typescript: require("../assets/file-icons/pierre_typescript.png"),
54 video: require("../assets/file-icons/pierre_video.png"),
54 vite: require("../assets/file-icons/pierre_vite.png"),55 vite: require("../assets/file-icons/pierre_vite.png"),
55 vscode: require("../assets/file-icons/pierre_vscode.png"),56 vscode: require("../assets/file-icons/pierre_vscode.png"),
56 vue: require("../assets/file-icons/pierre_vue.png"),57 vue: require("../assets/file-icons/pierre_vue.png"),
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+28-3
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
4} from "@t3tools/client-runtime/markdown-links";
5import { videoMimeType } from "@t3tools/shared/video";
6
1import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";7import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
28
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;9const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
...@@ -253,15 +259,22 @@ function normalizeDestination(value: string): string {...@@ -253,15 +259,22 @@ function normalizeDestination(value: string): string {
253 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;259 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
254}260}
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
256function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {267function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
257 try {268 try {
258 const parsed = new URL(href);269 const parsed = new URL(href);
259 if (parsed.protocol.toLowerCase() !== "file:") {270 if (parsed.protocol.toLowerCase() !== "file:") {
260 return null;271 return null;
261 }272 }
262 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)273 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
263 ? parsed.pathname.slice(1)274 const rawPath = uncHostname
275 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
264 : parsed.pathname;276 : parsed.pathname;
277 const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
265 return { path, hash: parsed.hash };278 return { path, hash: parsed.hash };
266 } catch {279 } catch {
267 return null;280 return null;
...@@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean {...@@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean {
327 if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {340 if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
328 return true;341 return true;
329 }342 }
343 if (isConventionalFilePosition(value)) return true;
330 return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);344 return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
331}345}
332346
...@@ -338,6 +352,7 @@ function fileLabel(value: string): string {...@@ -338,6 +352,7 @@ function fileLabel(value: string): string {
338352
339export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {353export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
340 const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();354 const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
355 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
341 const exactIcon = FILE_ICON_BY_NAME[basename];356 const exactIcon = FILE_ICON_BY_NAME[basename];
342 if (exactIcon) return exactIcon;357 if (exactIcon) return exactIcon;
343 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {358 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
...@@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {...@@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
354export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {369export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
355 const normalized = normalizeDestination(href);370 const normalized = normalizeDestination(href);
356 try {371 try {
357 const parsed = new URL(normalized);372 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
358 if (parsed.protocol === "http:" || parsed.protocol === "https:") {373 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
359 return {374 return {
360 kind: "external",375 kind: "external",
...@@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese...@@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
399 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,414 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
400 };415 };
401}416}
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 @@...@@ -1,7 +1,11 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
22
3import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";3import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
4import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks";4import {
5 resolveMarkdownInlineCodePresentation,
6 resolveMarkdownLinkPresentation,
7 type MarkdownFileIcon,
8} from "./markdownLinks";
59
6export interface NativeMarkdownTextRun {10export interface NativeMarkdownTextRun {
7 readonly text: string;11 readonly text: string;
...@@ -283,8 +287,17 @@ function appendNode(...@@ -283,8 +287,17 @@ function appendNode(
283 return appendRun(runs, textNodeContent(nodeTextContent(node)), context);287 return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
284 case "html_inline":288 case "html_inline":
285 return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);289 return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
286 case "code_inline":290 case "code_inline": {
287 return appendRun(runs, nodeTextContent(node), { ...context, code: true });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 }
288 case "soft_break":301 case "soft_break":
289 return appendRun(runs, " ", context);302 return appendRun(runs, " ", context);
290 case "line_break":303 case "line_break":
apps/mobile/src/components/FilePreview.ios.tsx+13-1
...@@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react";...@@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react";
3import { Alert } from "react-native";3import { Alert } from "react-native";
44
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6import { MediaImagePreview } from "./MediaImagePreview";
67
7const NativeControls = requireNativeModule<{8const NativeControls = requireNativeModule<{
8 presentFile(9 presentFile(
...@@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{...@@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{
14 dismissFile(identifier: string): Promise<void>;15 dismissFile(identifier: string): Promise<void>;
15}>("T3NativeControls");16}>("T3NativeControls");
1617
17export function FilePreview(props: {18function NativeFilePreview(props: {
18 readonly source: ResolvedFilePreviewSource;19 readonly source: ResolvedFilePreviewSource;
19 readonly onRequestClose: () => void;20 readonly onRequestClose: () => void;
20}) {21}) {
...@@ -41,3 +42,14 @@ export function FilePreview(props: {...@@ -41,3 +42,14 @@ export function FilePreview(props: {
4142
42 return null;43 return null;
43}44}
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";...@@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing";
44
5import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";5import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7import { MediaImagePreview } from "./MediaImagePreview";
78
8function PdfPreview(props: {9function PdfPreview(props: {
9 readonly source: ResolvedFilePreviewSource;10 readonly source: ResolvedFilePreviewSource;
...@@ -39,6 +40,7 @@ export function FilePreview(props: {...@@ -39,6 +40,7 @@ export function FilePreview(props: {
39 readonly onRequestClose: () => void;40 readonly onRequestClose: () => void;
40}) {41}) {
41 if (props.source.kind === "pdf") return <PdfPreview {...props} />;42 if (props.source.kind === "pdf") return <PdfPreview {...props} />;
43 if (props.source.actionsSource) return <MediaImagePreview {...props} />;
42 return (44 return (
43 <ImageViewing45 <ImageViewing
44 images={[{ uri: props.source.uri }]}46 images={[{ uri: props.source.uri }]}
apps/mobile/src/components/FilePreviewModal.tsx+12-4
...@@ -5,6 +5,7 @@ import { Alert, Keyboard } from "react-native";...@@ -5,6 +5,7 @@ import { Alert, Keyboard } from "react-native";
55
6import type { DraftComposerFileAttachment } from "../lib/composerImages";6import type { DraftComposerFileAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import type { MediaActionsSource } from "../lib/mediaActions";
8import { useAssetUrlState } from "../state/assets";9import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";10import { usePreparedConnection } from "../state/session";
10import { FilePreview } from "./FilePreview";11import { FilePreview } from "./FilePreview";
...@@ -14,6 +15,8 @@ export interface ResolvedFilePreviewSource {...@@ -14,6 +15,8 @@ export interface ResolvedFilePreviewSource {
14 readonly uri: string;15 readonly uri: string;
15 readonly name?: string;16 readonly name?: string;
16 readonly sourceIdentifier?: string;17 readonly sourceIdentifier?: string;
18 readonly srcFragment?: string;
19 readonly actionsSource?: MediaActionsSource;
17}20}
1821
19export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &22export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
...@@ -40,13 +43,18 @@ function ResolvedFilePreview(props: {...@@ -40,13 +43,18 @@ function ResolvedFilePreview(props: {
40 (connection._tag === "None" || asset._tag === "Failure");43 (connection._tag === "None" || asset._tag === "Failure");
41 useEffect(() => Keyboard.dismiss(), []);44 useEffect(() => Keyboard.dismiss(), []);
42 useEffect(() => {45 useEffect(() => {
43 if (uri === null && asset._tag === "Success") setUri(asset.url);46 if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? ""));
44 }, [uri, asset]);47 }, [uri, asset, source.srcFragment]);
45 useEffect(() => {48 useEffect(() => {
46 if (!failed) return;49 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 );
48 onRequestClose();56 onRequestClose();
49 }, [failed]);57 }, [failed, connection._tag]);
50 useEffect(() => {58 useEffect(() => {
51 if (!("attachment" in source)) return;59 if (!("attachment" in source)) return;
52 const controller = new AbortController();60 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";...@@ -7,9 +7,10 @@ import { Alert, Keyboard } from "react-native";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";9import { 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
14const NativeControls = requireNativeModule<{15const NativeControls = requireNativeModule<{
15 presentVideo(16 presentVideo(
...@@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{...@@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{
22}>("T3NativeControls");23}>("T3NativeControls");
2324
24function NativeVideoPreview(props: {25function NativeVideoPreview(props: {
25 readonly source: VideoPreviewSource;26 readonly source: AttachmentVideoPreviewSource;
26 readonly onRequestClose: () => void;27 readonly onRequestClose: () => void;
27}) {28}) {
28 const { source } = props;29 const { source } = props;
...@@ -117,5 +118,8 @@ export function VideoPreviewModal(props: {...@@ -117,5 +118,8 @@ export function VideoPreviewModal(props: {
117 }, [isFocused, hasSource]);118 }, [isFocused, hasSource]);
118119
119 if (!props.source || !isFocused) return null;120 if (!props.source || !isFocused) return null;
121 if (props.source.type === "media") {
122 return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />;
123 }
120 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;124 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;
121}125}
apps/mobile/src/components/VideoPreviewModal.tsx+7-11
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { useIsFocused } from "@react-navigation/native";1import { useIsFocused } from "@react-navigation/native";
2import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts";
3import { videoMimeType } from "@t3tools/shared/video";2import { videoMimeType } from "@t3tools/shared/video";
4import { useEvent } from "expo";3import { useEvent } from "expo";
5import { useVideoPlayer, VideoView } from "expo-video";4import { useVideoPlayer, VideoView } from "expo-video";
...@@ -19,21 +18,15 @@ import {...@@ -19,21 +18,15 @@ import {
19 downloadAttachmentForPreview,18 downloadAttachmentForPreview,
20 type AttachmentPreviewFile,19 type AttachmentPreviewFile,
21} from "../lib/attachmentDownload";20} from "../lib/attachmentDownload";
22import type { DraftComposerFileAttachment } from "../lib/composerImages";
23import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";21import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
22import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource";
24import { useAssetUrlState } from "../state/assets";23import { useAssetUrlState } from "../state/assets";
25import { usePreparedConnection } from "../state/session";24import { usePreparedConnection } from "../state/session";
26import { SymbolView } from "./AppSymbol";25import { SymbolView } from "./AppSymbol";
27import { AppText } from "./AppText";26import { AppText } from "./AppText";
27import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal";
2828
29export type VideoPreviewSource = (29export type { VideoPreviewSource } from "../lib/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 };
3730
38function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {31function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {
39 const player = useVideoPlayer(props.file.uri, (player) => {32 const player = useVideoPlayer(props.file.uri, (player) => {
...@@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {...@@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) {
120}113}
121114
122function OpenVideoPreviewModal(props: {115function OpenVideoPreviewModal(props: {
123 readonly source: VideoPreviewSource;116 readonly source: AttachmentVideoPreviewSource;
124 readonly onRequestClose: () => void;117 readonly onRequestClose: () => void;
125}) {118}) {
126 const { source } = props;119 const { source } = props;
...@@ -250,6 +243,9 @@ export function VideoPreviewModal(props: {...@@ -250,6 +243,9 @@ export function VideoPreviewModal(props: {
250 }, [isFocused, hasSource, props.onRequestClose]);243 }, [isFocused, hasSource, props.onRequestClose]);
251 const { source } = props;244 const { source } = props;
252 if (source === null || !isFocused) return null;245 if (source === null || !isFocused) return null;
246 if (source.type === "media") {
247 return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />;
248 }
253 const key =249 const key =
254 source.type === "local"250 source.type === "local"
255 ? `local:${source.attachment.id}:${source.attachment.fileUri}`251 ? `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...@@ -11,6 +11,7 @@ import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails
11export function VideoThumbnailImage(props: {11export function VideoThumbnailImage(props: {
12 readonly cacheKey: string;12 readonly cacheKey: string;
13 readonly source: string | DraftComposerFileAttachment | null;13 readonly source: string | DraftComposerFileAttachment | null;
14 readonly contentFit?: "cover" | "contain";
14}) {15}) {
15 const { cacheKey, source } = props;16 const { cacheKey, source } = props;
16 const isFocused = useIsFocused();17 const isFocused = useIsFocused();
...@@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: {...@@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: {
37 <Image38 <Image
38 source={thumbnail}39 source={thumbnail}
39 style={StyleSheet.absoluteFill}40 style={StyleSheet.absoluteFill}
40 contentFit="cover"41 contentFit={props.contentFit ?? "cover"}
41 recyclingKey={cacheKey}42 recyclingKey={cacheKey}
42 accessible={false}43 accessible={false}
43 />44 />
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+116-19
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { MenuAction } from "@react-native-menu/menu";3import type { MenuAction } from "@react-native-menu/menu";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
5import { ActivityIndicator, Platform, View } from "react-native";5import { ActivityIndicator, Platform, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";7import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
...@@ -11,6 +11,9 @@ import {...@@ -11,6 +11,9 @@ import {
11 type ProjectReadFileResult,11 type ProjectReadFileResult,
12 ThreadId,12 ThreadId,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { videoMimeType } from "@t3tools/shared/video";
15import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
16import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1417
15import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";18import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
16import { SymbolView } from "../../components/AppSymbol";19import { SymbolView } from "../../components/AppSymbol";
...@@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";...@@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24import { isPdfFile } from "../../lib/filePreview";27import { isPdfFile } from "../../lib/filePreview";
25import { tryOpenExternalUrl } from "../../lib/openExternalUrl";28import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
26import { useUniwindTheme } from "../../lib/useUniwindTheme";29import { useUniwindTheme } from "../../lib/useUniwindTheme";
30import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
31import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
27import { useThreadSelection } from "../../state/use-thread-selection";32import { useThreadSelection } from "../../state/use-thread-selection";
28import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";33import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
29import { useEnvironmentQuery } from "../../state/query";34import { useEnvironmentQuery } from "../../state/query";
...@@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";...@@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";
47import { SourceFileSurface } from "./SourceFileSurface";52import { SourceFileSurface } from "./SourceFileSurface";
48import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";53import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
49import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";54import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
55import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
50import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";56import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
51import {57import {
52 basename,58 basename,
...@@ -54,8 +60,9 @@ import {...@@ -54,8 +60,9 @@ import {
54 isImagePreviewFile,60 isImagePreviewFile,
55 isMarkdownPreviewFile,61 isMarkdownPreviewFile,
56 isSvgImagePreviewFile,62 isSvgImagePreviewFile,
63 isVideoPreviewFile,
57} from "./filePath";64} from "./filePath";
58import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl";65import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";
5966
60type FileViewMode = "preview" | "source";67type FileViewMode = "preview" | "source";
6168
...@@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null {...@@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null {
84}91}
8592
86function defaultViewMode(path: string | null): FileViewMode {93function defaultViewMode(path: string | null): FileViewMode {
87 return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))94 return path !== null &&
95 (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path))
88 ? "preview"96 ? "preview"
89 : "source";97 : "source";
90}98}
...@@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode {...@@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode {
92function FileContent(props: {100function FileContent(props: {
93 readonly activeMode: FileViewMode;101 readonly activeMode: FileViewMode;
94 readonly previewUri: string | null;102 readonly previewUri: string | null;
103 readonly previewUnavailable: boolean;
104 readonly videoSource: MediaVideoPreviewSource | null;
105 readonly mediaSource?: MediaActionsSource;
106 readonly resolveVideoUri: () => Promise<string | null>;
95 readonly fileContents: string | null;107 readonly fileContents: string | null;
96 readonly fileError: string | null;108 readonly fileError: string | null;
97 readonly relativePath: string;109 readonly relativePath: string;
...@@ -99,10 +111,25 @@ function FileContent(props: {...@@ -99,10 +111,25 @@ function FileContent(props: {
99 readonly truncated: boolean;111 readonly truncated: boolean;
100 readonly onRefresh?: () => Promise<void> | void;112 readonly onRefresh?: () => Promise<void> | void;
101}) {113}) {
114 // Reopening a mutable host file must not reuse a poster from an earlier visit.
115 const thumbnailInstanceId = useId();
102 const isMarkdown = isMarkdownPreviewFile(props.relativePath);116 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
103 const isBrowserFile = isBrowserPreviewFile(props.relativePath);117 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
104 const isImageFile = isImagePreviewFile(props.relativePath);118 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
106 if (props.activeMode === "preview" && isImageFile) {133 if (props.activeMode === "preview" && isImageFile) {
107 if (isSvgImagePreviewFile(props.relativePath)) {134 if (isSvgImagePreviewFile(props.relativePath)) {
108 return <WorkspaceFileWebPreview uri={props.previewUri} />;135 return <WorkspaceFileWebPreview uri={props.previewUri} />;
...@@ -111,6 +138,7 @@ function FileContent(props: {...@@ -111,6 +138,7 @@ function FileContent(props: {
111 <WorkspaceFileImagePreview138 <WorkspaceFileImagePreview
112 accessibilityLabel={basename(props.relativePath)}139 accessibilityLabel={basename(props.relativePath)}
113 uri={props.previewUri}140 uri={props.previewUri}
141 actionsSource={props.mediaSource}
114 />142 />
115 );143 );
116 }144 }
...@@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
489 readonly mode: FileViewMode;517 readonly mode: FileViewMode;
490 } | null>(null);518 } | null>(null);
491 const [previewRevision, setPreviewRevision] = useState(0);519 const [previewRevision, setPreviewRevision] = useState(0);
520 const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]);
492 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);521 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);522 const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath);
494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);523 const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath);
524 const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath);
495 const canPreview =525 const canPreview =
496 relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile);526 relativePath !== null &&
527 (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile);
497 const activeMode =528 const activeMode =
498 relativePath !== null && modeOverride?.path === relativePath529 relativePath !== null && modeOverride?.path === relativePath
499 ? modeOverride.mode530 ? modeOverride.mode
500 : defaultViewMode(relativePath);531 : defaultViewMode(relativePath);
501 const resolvedActiveMode = canPreview ? activeMode : "source";532 const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source";
502 const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;533 const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null;
503 const assetPreviewUri = useWorkspaceFileAssetUrl({534 const assetPreview = useWorkspaceFileAssetUrlState({
504 cwd,535 cwd,
505 environmentId,536 environmentId,
506 relativePath: assetPreviewPath,537 relativePath: assetPreviewPath,
507 threadId,538 threadId,
508 });539 });
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 );
509 const previewUri =580 const previewUri =
510 assetPreviewUri === null || previewRevision === 0581 assetPreviewUri === null || previewRevision === 0
511 ? assetPreviewUri582 ? assetPreviewUri
512 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;583 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
513 const needsFileContents =584 const needsFileContents =
514 relativePath !== null &&585 relativePath !== null &&
586 !isVideoFile &&
515 (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));587 (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
516 const fileQuery = useEnvironmentQuery(588 const fileQuery = useEnvironmentQuery(
517 environmentId !== null && cwd !== null && relativePath !== null && needsFileContents589 environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
...@@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
562634
563 const fileMenuActions = useMemo(() => {635 const fileMenuActions = useMemo(() => {
564 if (relativePath === null) return [];636 if (relativePath === null) return [];
565 const canToggleMode = canPreview && !isImageFile;637 const canToggleMode = canPreview && !isImageFile && !isVideoFile;
566 return [638 return [
567 canToggleMode639 canToggleMode
568 ? ({640 ? ({
...@@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
582 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),654 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
583 } as const)655 } as const)
584 : null,656 : null,
585 {657 ...(mediaSource
586 id: "copy-path",658 ? mediaActions.actions
587 title: "Copy path",659 .filter(({ id }) => id !== "open-file")
588 icon: "doc.on.doc",660 .map((action) => ({
589 inline: false,661 id: action.id,
590 onPress: () => copyTextWithHaptic(relativePath),662 title: action.title,
591 } as const,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 ]),
592 isPdfFile({ name: relativePath }) && previewUri !== null677 isPdfFile({ name: relativePath }) && previewUri !== null
593 ? ({678 ? ({
594 id: "open-pdf",679 id: "open-pdf",
...@@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
612 onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),697 onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),
613 } as const)698 } as const)
614 : null,699 : null,
615 resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)700 resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile)
616 ? ({701 ? ({
617 id: "refresh",702 id: "refresh",
618 title: "Refresh",703 title: "Refresh",
619 icon: "arrow.clockwise",704 icon: "arrow.clockwise",
620 inline: false,705 inline: false,
621 onPress: () => setPreviewRevision((current) => current + 1),706 onPress: async () => {
707 if (isVideoFile) await assetPreview.refresh();
708 setPreviewRevision((current) => current + 1);
709 },
622 } as const)710 } as const)
623 : null,711 : null,
624 ].filter((action) => action !== null);712 ].filter((action) => action !== null);
625 }, [713 }, [
626 assetPreviewUri,714 assetPreviewUri,
715 assetPreview.refresh,
627 previewUri,716 previewUri,
628 canPreview,717 canPreview,
629 isBrowserFile,718 isBrowserFile,
630 isImageFile,719 isImageFile,
720 isVideoFile,
631 relativePath,721 relativePath,
632 resolvedActiveMode,722 resolvedActiveMode,
723 mediaSource,
724 mediaActions.actions,
633 ]);725 ]);
634726
635 const androidFileMenuActions = useMemo<MenuAction[]>(727 const androidFileMenuActions = useMemo<MenuAction[]>(
...@@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
782 </NativeHeaderToolbar.Menu>874 </NativeHeaderToolbar.Menu>
783 </NativeHeaderToolbar>875 </NativeHeaderToolbar>
784 <FileContent876 <FileContent
877 key={previewKey}
785 activeMode={resolvedActiveMode}878 activeMode={resolvedActiveMode}
786 previewUri={previewUri}879 previewUri={previewUri}
880 previewUnavailable={assetPreview._tag === "Failure"}
881 videoSource={videoSource}
882 mediaSource={mediaSource}
883 resolveVideoUri={assetPreview.refresh}
787 fileContents={fileData?.contents ?? null}884 fileContents={fileData?.contents ?? null}
788 fileError={fileQuery.error}885 fileError={fileQuery.error}
789 initialLine={targetLine}886 initialLine={targetLine}
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+13-1
...@@ -8,14 +8,18 @@ import { EmptyState } from "../../components/EmptyState";...@@ -8,14 +8,18 @@ import { EmptyState } from "../../components/EmptyState";
8import { workspaceFileImageAtom } from "./workspace-file-image-cache";8import { workspaceFileImageAtom } from "./workspace-file-image-cache";
9import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";9import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
10import { PresentationSource } from "../../components/NativePresentation";10import { PresentationSource } from "../../components/NativePresentation";
11import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
12import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1113
12function ResolvedWorkspaceFileImagePreview(props: {14function ResolvedWorkspaceFileImagePreview(props: {
13 readonly accessibilityLabel: string;15 readonly accessibilityLabel: string;
14 readonly uri: string;16 readonly uri: string;
17 readonly actionsSource?: MediaActionsSource;
15}) {18}) {
16 const [loadError, setLoadError] = useState<string | null>(null);19 const [loadError, setLoadError] = useState<string | null>(null);
17 const [preview, setPreview] = useState<FilePreviewSource | null>(null);20 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
18 const sourceIdentifier = useId();21 const sourceIdentifier = useId();
22 const mediaActions = useMediaActions(props.actionsSource);
19 const imageSource = useMemo(23 const imageSource = useMemo(
20 () => ({ uri: props.uri, cache: "force-cache" as const }),24 () => ({ uri: props.uri, cache: "force-cache" as const }),
21 [props.uri],25 [props.uri],
...@@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: {...@@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
34 uri: props.uri,38 uri: props.uri,
35 name: props.accessibilityLabel,39 name: props.accessibilityLabel,
36 sourceIdentifier,40 sourceIdentifier,
41 actionsSource: props.actionsSource,
37 })42 })
38 }43 }
39 >44 >
...@@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: {...@@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: {
50 />55 />
51 </PresentationSource>56 </PresentationSource>
52 </Pressable>57 </Pressable>
53
54 {loadError !== null ? (58 {loadError !== null ? (
55 <View className="absolute inset-0 items-center justify-center bg-card px-6">59 <View className="absolute inset-0 items-center justify-center bg-card px-6">
56 <EmptyState title="Image unavailable" detail={loadError} />60 <EmptyState title="Image unavailable" detail={loadError} />
57 </View>61 </View>
58 ) : null}62 ) : null}
5963
64 <View className="absolute right-2 top-2">
65 <MediaActionsMenu media={mediaActions} />
66 </View>
67
60 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />68 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
61 </View>69 </View>
62 );70 );
...@@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: {...@@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: {
65function CachedWorkspaceFileImagePreview(props: {73function CachedWorkspaceFileImagePreview(props: {
66 readonly accessibilityLabel: string;74 readonly accessibilityLabel: string;
67 readonly uri: string;75 readonly uri: string;
76 readonly actionsSource?: MediaActionsSource;
68}) {77}) {
69 const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]);78 const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]);
70 const imageResult = useAtomValue(imageAtom);79 const imageResult = useAtomValue(imageAtom);
...@@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: {...@@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: {
93 <ResolvedWorkspaceFileImagePreview102 <ResolvedWorkspaceFileImagePreview
94 accessibilityLabel={props.accessibilityLabel}103 accessibilityLabel={props.accessibilityLabel}
95 uri={imageResult.value}104 uri={imageResult.value}
105 actionsSource={props.actionsSource}
96 />106 />
97 );107 );
98}108}
...@@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: {...@@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: {
100export function WorkspaceFileImagePreview(props: {110export function WorkspaceFileImagePreview(props: {
101 readonly accessibilityLabel: string;111 readonly accessibilityLabel: string;
102 readonly uri: string | null;112 readonly uri: string | null;
113 readonly actionsSource?: MediaActionsSource;
103}) {114}) {
104 if (props.uri === null) {115 if (props.uri === null) {
105 return (116 return (
...@@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: {...@@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: {
116 <CachedWorkspaceFileImagePreview127 <CachedWorkspaceFileImagePreview
117 accessibilityLabel={props.accessibilityLabel}128 accessibilityLabel={props.accessibilityLabel}
118 uri={props.uri}129 uri={props.uri}
130 actionsSource={props.actionsSource}
119 />131 />
120 );132 );
121}133}
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 @@...@@ -1,6 +1,7 @@
1import {1import {
2 isWorkspaceBrowserPreviewPath,2 isWorkspaceBrowserPreviewPath,
3 isWorkspaceImagePreviewPath,3 isWorkspaceImagePreviewPath,
4 isWorkspaceVideoPreviewPath,
4} from "@t3tools/shared/filePreview";5} from "@t3tools/shared/filePreview";
56
6export interface FileBreadcrumb {7export interface FileBreadcrumb {
...@@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean {...@@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean {
95 return isWorkspaceImagePreviewPath(path);96 return isWorkspaceImagePreviewPath(path);
96}97}
9798
99export function isVideoPreviewFile(path: string): boolean {
100 return isWorkspaceVideoPreviewPath(path);
101}
102
98export function isSvgImagePreviewFile(path: string): boolean {103export function isSvgImagePreviewFile(path: string): boolean {
99 return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");104 return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
100}105}
apps/mobile/src/features/files/preload-workspace-file.ts+6-2
...@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
33
4import { appAtomRegistry } from "../../state/atom-registry";4import { appAtomRegistry } from "../../state/atom-registry";
5import { projectEnvironment } from "../../state/projects";5import { projectEnvironment } from "../../state/projects";
6import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath";6import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath";
7import { prepareSourceFileDocument } from "./source-file-document";7import { prepareSourceFileDocument } from "./source-file-document";
8import { sourceHighlightAtom } from "./sourceHighlightingState";8import { sourceHighlightAtom } from "./sourceHighlightingState";
9import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";9import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
...@@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: {...@@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: {
25 readonly relativePath: string;25 readonly relativePath: string;
26 readonly theme: ReviewDiffTheme;26 readonly theme: ReviewDiffTheme;
27}): void {27}): void {
28 if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) {28 if (
29 isBrowserPreviewFile(input.relativePath) ||
30 isImagePreviewFile(input.relativePath) ||
31 isVideoPreviewFile(input.relativePath)
32 ) {
29 return;33 return;
30 }34 }
3135
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+17-13
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useMemo } from "react";2import { useMemo } from "react";
33
4import { useAssetUrl } from "../../state/assets";4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5import { resolveWorkspaceFilePath } from "./filePath";5import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
66
7export function useWorkspaceFileAssetUrl(props: {7export function useWorkspaceFileAssetUrlState(props: {
8 readonly cwd: string | null;8 readonly cwd: string | null;
9 readonly environmentId: EnvironmentId | null;9 readonly environmentId: EnvironmentId | null;
10 readonly relativePath: string | null;10 readonly relativePath: string | null;
...@@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: {...@@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: {
18 [props.cwd, props.relativePath],18 [props.cwd, props.relativePath],
19 );19 );
2020
21 return useAssetUrl(21 const resource = useMemo<AssetResource | null>(
22 props.environmentId,22 () =>
23 absolutePath !== null && props.threadId !== null23 absolutePath !== null && props.threadId !== null
24 ? {24 ? {
25 _tag: "workspace-file",25 _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file",
26 threadId: props.threadId,26 threadId: props.threadId,
27 path: absolutePath,27 path: absolutePath,
28 }28 }
29 : null,29 : null,
30 [absolutePath, props.threadId],
30 );31 );
32 const state = useAssetUrlState(props.environmentId, resource);
33 const refresh = useRefreshAssetUrl(props.environmentId, resource);
34 return { ...state, resource, refresh };
31}35}
apps/mobile/src/features/threads/ThreadFeed.tsx+262-96
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
4import type {4import type {
5 AssetResource,5 AssetResource,
6 ChatAttachment,6 ChatAttachment,
...@@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";...@@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
33import { HeaderHeightContext } from "@react-navigation/elements";33import { HeaderHeightContext } from "@react-navigation/elements";
34import { useFocusEffect, useNavigation } from "@react-navigation/native";34import { useFocusEffect, useNavigation } from "@react-navigation/native";
35import {35import {
36 createContext,
36 memo,37 memo,
37 useCallback,38 useCallback,
38 useContext,39 useContext,
...@@ -97,6 +98,15 @@ import {...@@ -97,6 +98,15 @@ import {
97import { AppText as Text } from "../../components/AppText";98import { AppText as Text } from "../../components/AppText";
98import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";99import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
99import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";100import { 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";
100import { CopyTextButton } from "../../components/CopyTextButton";110import { CopyTextButton } from "../../components/CopyTextButton";
101import {111import {
102 parseReviewCommentMessageSegments,112 parseReviewCommentMessageSegments,
...@@ -123,7 +133,11 @@ import {...@@ -123,7 +133,11 @@ import {
123import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";133import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
124import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";134import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
125import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";135import { 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";
127import {141import {
128 deriveThreadFeedPresentation,142 deriveThreadFeedPresentation,
129 type ThreadFeedEntry,143 type ThreadFeedEntry,
...@@ -143,7 +157,12 @@ import {...@@ -143,7 +157,12 @@ import {
143 WORK_GROUP_TOGGLE_HEIGHT,157 WORK_GROUP_TOGGLE_HEIGHT,
144} from "./thread-work-log";158} from "./thread-work-log";
145import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";159import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
146import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";160import {
161 assetEnvironment,
162 useAssetUrl,
163 useAssetUrlState,
164 useRefreshAssetUrl,
165} from "../../state/assets";
147import { useAtomQueryRunner } from "../../state/use-atom-query-runner";166import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
148import { usePreparedConnection } from "../../state/session";167import { usePreparedConnection } from "../../state/session";
149import * as Option from "effect/Option";168import * as Option from "effect/Option";
...@@ -272,6 +291,7 @@ function MessageAttachmentFile(props: {...@@ -272,6 +291,7 @@ function MessageAttachmentFile(props: {
272}) {291}) {
273 const sourceIdentifier = useId();292 const sourceIdentifier = useId();
274 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {293 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
294 refresh: true,
275 reportFailure: false,295 reportFailure: false,
276 });296 });
277 const preparedConnection = usePreparedConnection(props.environmentId);297 const preparedConnection = usePreparedConnection(props.environmentId);
...@@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: {...@@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: {
453 readonly sourceKey: string;473 readonly sourceKey: string;
454 readonly unavailable: boolean;474 readonly unavailable: boolean;
455 readonly alt: string | null;475 readonly alt: string | null;
476 readonly actionsSource?: MediaActionsSource;
456 readonly onPressPreview: (source: FilePreviewSource) => void;477 readonly onPressPreview: (source: FilePreviewSource) => void;
457}) {478}) {
458 const sourceIdentifier = useId();479 const sourceIdentifier = useId();
480 const mediaActions = useMediaActions(props.actionsSource);
459 const [availableWidth, setAvailableWidth] = useState(0);481 const [availableWidth, setAvailableWidth] = useState(0);
460 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);482 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
461 const [failedUri, setFailedUri] = useState<string | null>(null);483 const [failedUri, setFailedUri] = useState<string | null>(null);
...@@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: {...@@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: {
498 ) : (520 ) : (
499 <ActivityIndicator />521 <ActivityIndicator />
500 )}522 )}
523 {props.actionsSource ? (
524 <View className="absolute right-1 top-1">
525 <MediaActionsMenu media={mediaActions} />
526 </View>
527 ) : null}
501 </View>528 </View>
502 ) : (529 ) : (
503 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>530 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
504 <Pressable531 <View>
505 accessibilityRole="imagebutton"532 <MediaActionsMenu media={mediaActions}>
506 accessibilityLabel={props.alt ?? "Markdown image"}533 <Pressable
507 onPress={() =>534 accessibilityRole="imagebutton"
508 props.onPressPreview({535 accessibilityLabel={props.alt ?? "Markdown image"}
509 kind: "image",536 onPress={() =>
510 uri: props.uri!,537 props.onPressPreview({
511 name: props.alt ?? "Image",538 kind: "image",
512 sourceIdentifier,539 uri: props.uri!,
513 })540 name: props.alt ?? "Image",
514 }541 sourceIdentifier,
515 style={{ alignSelf: "flex-start" }}542 actionsSource: props.actionsSource,
516 >543 })
517 <View544 }
518 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"545 style={{ alignSelf: "flex-start" }}
519 style={{546 >
520 ...frameStyle,547 <View
521 }}548 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
522 >549 style={{
523 <ThreadMarkdownImageRequest550 ...frameStyle,
524 key={props.uri}551 }}
525 uri={props.uri}552 >
526 onLoad={setSourceSize}553 <ThreadMarkdownImageRequest
527 onError={() => setFailedUri(props.uri)}554 key={props.uri}
528 />555 uri={props.uri}
529 </View>556 onLoad={setSourceSize}
530 </Pressable>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>
531 </PresentationSource>568 </PresentationSource>
532 )}569 )}
533 {props.alt ? (570 {props.alt ? (
...@@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: {...@@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: {
574/** Environment-hosted image that loads through a signed asset URL. */611/** Environment-hosted image that loads through a signed asset URL. */
575function ThreadMarkdownImage(props: {612function ThreadMarkdownImage(props: {
576 readonly environmentId: EnvironmentId;613 readonly environmentId: EnvironmentId;
577 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;614 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
578 readonly alt: string | null;615 readonly alt: string | null;
579 readonly srcFragment?: string;616 readonly srcFragment?: string;
617 readonly actionsSource?: MediaActionsSource;
580 readonly onPressPreview: (source: FilePreviewSource) => void;618 readonly onPressPreview: (source: FilePreviewSource) => void;
581}) {619}) {
582 const assetUrl = useAssetUrlState(props.environmentId, props.resource);620 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
...@@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: {...@@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: {
591 }629 }
592 unavailable={assetUrl._tag === "Failure"}630 unavailable={assetUrl._tag === "Failure"}
593 alt={props.alt}631 alt={props.alt}
632 actionsSource={props.actionsSource}
594 onPressPreview={props.onPressPreview}633 onPressPreview={props.onPressPreview}
595 />634 />
596 );635 );
597}636}
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
599function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {685function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
600 return (686 return (
601 <ThreadMarkdownImageView687 <ThreadMarkdownImageView
...@@ -636,6 +722,7 @@ interface ReviewCommentColors {...@@ -636,6 +722,7 @@ interface ReviewCommentColors {
636}722}
637723
638const failedMarkdownFaviconHosts = new Set<string>();724const failedMarkdownFaviconHosts = new Set<string>();
725const MarkdownLinkLabelContext = createContext(false);
639const markdownLinkStyles = StyleSheet.create({726const markdownLinkStyles = StyleSheet.create({
640 inlineIcon: {727 inlineIcon: {
641 width: 14,728 width: 14,
...@@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
653 readonly color: string;740 readonly color: string;
654 readonly host: string;741 readonly host: string;
655 readonly href: string;742 readonly href: string;
743 readonly onPress: (href: string) => void;
656}) {744}) {
657 const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));745 const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));
658746
659 return (747 return (
660 <NativeText748 <NativeText
661 className="font-sans"749 className="font-sans"
662 onPress={() => {750 onPress={() => props.onPress(props.href)}
663 void tryOpenExternalUrl(props.href, "markdown-link");
664 }}
665 style={{751 style={{
666 color: props.color,752 color: props.color,
667 textDecorationLine: "none",753 textDecorationLine: "none",
...@@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
686 );772 );
687});773});
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
689const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<806const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
690 CodexArtifactTemplate["artifactKind"],807 CodexArtifactTemplate["artifactKind"],
691 AppSymbolName808 AppSymbolName
...@@ -1078,30 +1195,35 @@ function useMarkdownStyles(...@@ -1078,30 +1195,35 @@ function useMarkdownStyles(
1078 }1195 }
1079 if (presentation.kind === "external") {1196 if (presentation.kind === "external") {
1080 return (1197 return (
1081 <MarkdownExternalLink1198 <MarkdownLinkLabelContext.Provider value>
1082 href={presentation.href}1199 <MarkdownExternalLink
1083 host={presentation.host}1200 href={presentation.href}
1084 color={markdownLinkColor}1201 host={presentation.host}
1085 >1202 color={markdownLinkColor}
1086 {children}1203 onPress={onLinkPress}
1087 </MarkdownExternalLink>1204 >
1205 {children}
1206 </MarkdownExternalLink>
1207 </MarkdownLinkLabelContext.Provider>
1088 );1208 );
1089 }1209 }
1090 const linkHref = presentation.href;1210 const linkHref = presentation.href;
1091 return (1211 return (
1092 <NativeText1212 <MarkdownLinkLabelContext.Provider value>
1093 className="underline"1213 <NativeText
1094 onPress={1214 className="underline"
1095 linkHref1215 onPress={
1096 ? () => {1216 linkHref
1097 void tryOpenExternalUrl(linkHref, "markdown-link");1217 ? () => {
1098 }1218 void tryOpenExternalUrl(linkHref, "markdown-link");
1099 : undefined1219 }
1100 }1220 : undefined
1101 style={{ color: markdownLinkColor }}1221 }
1102 >1222 style={{ color: markdownLinkColor }}
1103 {children}1223 >
1104 </NativeText>1224 {children}
1225 </NativeText>
1226 </MarkdownLinkLabelContext.Provider>
1105 );1227 );
1106 },1228 },
1107 list: ({ node, Renderer, ordered = false, start = 1 }) => (1229 list: ({ node, Renderer, ordered = false, start = 1 }) => (
...@@ -1144,21 +1266,16 @@ function useMarkdownStyles(...@@ -1144,21 +1266,16 @@ function useMarkdownStyles(
1144 title: node.title ?? null,1266 title: node.title ?? null,
1145 }) ?? undefined)1267 }) ?? undefined)
1146 : undefined,1268 : undefined,
1147 code_inline: ({ content }) => {1269 code_inline: ({ content }) => (
1148 const value = content ?? "";1270 <MarkdownInlineCode
1149 return (1271 content={content ?? ""}
1150 <NativeText1272 textColor={inlineTextColor}
1151 className="font-mono"1273 codeColor={inlineCodeTextColor}
1152 style={{1274 fontSize={markdownFontSizes.codeBlockFontSize}
1153 color: inlineCodeTextColor,1275 lineHeight={markdownFontSizes.bodyLineHeight}
1154 fontSize: markdownFontSizes.codeBlockFontSize,1276 onLinkPress={onLinkPress}
1155 lineHeight: markdownFontSizes.bodyLineHeight,1277 />
1156 }}1278 ),
1157 >
1158 {value}
1159 </NativeText>
1160 );
1161 },
1162 ...(preserveSoftBreaks1279 ...(preserveSoftBreaks
1163 ? {1280 ? {
1164 soft_break: () => <NativeText>{"\n"}</NativeText>,1281 soft_break: () => <NativeText>{"\n"}</NativeText>,
...@@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1954 path: relativePath.split("/").filter((segment) => segment.length > 0),2071 path: relativePath.split("/").filter((segment) => segment.length > 0),
1955 ...(presentation.line ? { line: String(presentation.line) } : {}),2072 ...(presentation.line ? { line: String(presentation.line) } : {}),
1956 });2073 });
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);
1957 }2089 }
1958 return;2090 return;
1959 }2091 }
19602092
1961 if (presentation.href) {2093 if (presentation.kind !== "file" && presentation.href) {
1962 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {2094 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
1963 setExpandedFile(2095 setExpandedFile(
1964 (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },2096 (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },
...@@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1972 );2104 );
1973 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(2105 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
1974 (image) => {2106 (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 }
1975 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);2122 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
1976 if (imageSource._tag === "Direct") {2123 if (imageSource._tag === "Direct") {
1977 return (2124 return (
1978 <ThreadMarkdownImageView2125 <ThreadMarkdownImageView
1979 uri={imageSource.uri}2126 uri={normalizeNativeMarkdownUrl(imageSource.uri)}
1980 sourceKey={imageSource.uri}2127 sourceKey={imageSource.uri}
1981 unavailable={false}2128 unavailable={false}
1982 alt={image.alt}2129 alt={image.alt}
2130 actionsSource={media?.source.actionsSource}
1983 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2131 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1984 />2132 />
1985 );2133 );
...@@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1991 <ThreadMarkdownImage2139 <ThreadMarkdownImage
1992 environmentId={props.environmentId}2140 environmentId={props.environmentId}
1993 resource={{2141 resource={{
1994 _tag: "workspace-file",2142 _tag: "media-file",
1995 threadId: props.threadId,2143 threadId: props.threadId,
1996 path: imageSource.path,2144 path: imageSource.path,
1997 }}2145 }}
1998 alt={image.alt}2146 alt={image.alt}
1999 srcFragment={markdownImageSourceFragment(image.href)}2147 srcFragment={markdownImageSourceFragment(image.href)}
2148 actionsSource={media?.source.actionsSource}
2000 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2149 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2001 />2150 />
2002 );2151 );
...@@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2009 threadId: props.threadId,2158 threadId: props.threadId,
2010 workspaceRoot: props.workspaceRoot,2159 workspaceRoot: props.workspaceRoot,
2011 });2160 });
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;
2012 return viewedImage ? (2170 return viewedImage ? (
2013 <ThreadMarkdownImage2171 <ThreadMarkdownImage
2014 environmentId={props.environmentId}2172 environmentId={props.environmentId}
2015 resource={viewedImage.resource}2173 resource={viewedImage.resource}
2016 alt={viewedImage.alt}2174 alt={viewedImage.alt}
2017 srcFragment={viewedImage.srcFragment}2175 srcFragment={viewedImage.srcFragment}
2176 actionsSource={
2177 actionsSource && "resource" in actionsSource
2178 ? { ...actionsSource, resource: viewedImage.resource }
2179 : undefined
2180 }
2018 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2181 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2019 />2182 />
2020 ) : null;2183 ) : null;
...@@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2438 key={info.item.id}2601 key={info.item.id}
2439 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}2602 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
2440 >2603 >
2441 {renderFeedEntry(info, {2604 <ThreadMediaVisibility>
2442 environmentId: props.environmentId,2605 {renderFeedEntry(info, {
2443 copiedRowId,2606 environmentId: props.environmentId,
2444 expandedWorkRows,2607 copiedRowId,
2445 terminalAssistantMessageIds,2608 expandedWorkRows,
2446 unsettledTurnId,2609 terminalAssistantMessageIds,
2447 onCopyWorkRow,2610 unsettledTurnId,
2448 onToggleWorkGroup,2611 onCopyWorkRow,
2449 onToggleWorkRow,2612 onToggleWorkGroup,
2450 onToggleTurnFold,2613 onToggleWorkRow,
2451 onPressPreview,2614 onToggleTurnFold,
2452 onPressVideo,2615 onPressPreview,
2453 onMarkdownLinkPress,2616 onPressVideo,
2454 renderMarkdownImage,2617 onMarkdownLinkPress,
2455 renderViewedImage,2618 renderMarkdownImage,
2456 iconSubtleColor,2619 renderViewedImage,
2457 userBubbleColor,2620 iconSubtleColor,
2458 markdownStyles,2621 userBubbleColor,
2459 reviewCommentColors,2622 markdownStyles,
2460 reviewCommentBubbleWidth,2623 reviewCommentColors,
2461 userBubbleMaxWidth,2624 reviewCommentBubbleWidth,
2462 skills: props.skills,2625 userBubbleMaxWidth,
2463 onUseArtifactTemplate: props.onUseArtifactTemplate,2626 skills: props.skills,
2464 })}2627 onUseArtifactTemplate: props.onUseArtifactTemplate,
2628 })}
2629 </ThreadMediaVisibility>
2465 </Animated.View>2630 </Animated.View>
2466 ),2631 ),
2467 [2632 [
...@@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2586 data={presentedFeed}2751 data={presentedFeed}
2587 extraData={listAppearanceData}2752 extraData={listAppearanceData}
2588 renderItem={renderItem}2753 renderItem={renderItem}
2754 viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG}
2589 keyExtractor={(entry) => entry.id}2755 keyExtractor={(entry) => entry.id}
2590 getItemType={(entry) =>2756 getItemType={(entry) =>
2591 entry.type === "message" ? `message:${entry.message.role}` : entry.type2757 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";...@@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test";
3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
44
5describe("resolveMarkdownLinkPresentation", () => {5describe("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
6 it("extracts external link hosts", () => {22 it("extracts external link hosts", () => {
7 expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({23 expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({
8 kind: "external",24 kind: "external",
...@@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => {...@@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => {
11 });27 });
12 });28 });
1329
14 it("renders file URLs as basename pills with positions", () => {30 it.each([
15 expect(31 ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"],
16 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),32 ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"],
17 ).toEqual({33 ])("preserves the file URL path and position for %s", (href, path) => {
34 expect(resolveMarkdownLinkPresentation(href)).toEqual({
18 kind: "file",35 kind: "file",
19 href: "file:///Users/julius/project/src/main.ts#L42C7",36 href,
20 icon: "typescript",37 icon: "typescript",
21 label: "main.ts:42:7",38 label: "main.ts:42:7",
22 path: "/Users/julius/project/src/main.ts",39 path,
23 line: 42,40 line: 42,
24 column: 7,41 column: 7,
25 });42 });
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 {...@@ -11,6 +11,43 @@ import {
11} from "@t3tools/mobile-markdown-text/markdown";11} from "@t3tools/mobile-markdown-text/markdown";
1212
13describe("nativeMarkdownTextRuns", () => {13describe("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
14 it("preserves inline emphasis and code styles", () => {51 it("preserves inline emphasis and code styles", () => {
15 const node: MarkdownNode = {52 const node: MarkdownNode = {
16 type: "paragraph",53 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";...@@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react";
2import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";2import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useCallback } from "react";
56
6import { connectionAtomRuntime } from "../connection/runtime";7import { connectionAtomRuntime } from "../connection/runtime";
7import { usePreparedConnection } from "./session";8import { usePreparedConnection } from "./session";
9import { useAtomQueryRunner } from "./use-atom-query-runner";
810
9export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);11export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
1012
...@@ -44,3 +46,23 @@ export function useAssetUrl(...@@ -44,3 +46,23 @@ export function useAssetUrl(
44 const state = useAssetUrlState(environmentId, resource);46 const state = useAssetUrlState(environmentId, resource);
45 return state._tag === "Success" ? state.url : null;47 return state._tag === "Success" ? state.url : null;
46}48}
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 @@...@@ -1,7 +1,7 @@
1import { RegistryContext } from "@effect/atom-react";1import { RegistryContext } from "@effect/atom-react";
2import {2import {
3 executeAtomQuery,3 executeAtomQuery,
4 type AtomCommandOptions,4 type AtomQueryOptions,
5 type AtomCommandResult,5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { AsyncResult, type Atom } from "effect/unstable/reactivity";7import { AsyncResult, type Atom } from "effect/unstable/reactivity";
...@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";...@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";
99
10export function useAtomQueryRunner<T, A, E>(10export function useAtomQueryRunner<T, A, E>(
11 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,11 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,
12 options?: string | AtomCommandOptions,12 options?: string | AtomQueryOptions,
13): (target: T) => Promise<AtomCommandResult<A, E>> {13): (target: T) => Promise<AtomCommandResult<A, E>> {
14 const registry = useContext(RegistryContext);14 const registry = useContext(RegistryContext);
15 const explicitLabel = typeof options === "string" ? options : options?.label;15 const explicitLabel = typeof options === "string" ? options : options?.label;
16 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);16 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);
17 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);17 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);
18 const refresh = typeof options === "string" ? false : (options?.refresh ?? false);
1819
19 return useCallback(20 return useCallback(
20 (target: T) => {21 (target: T) => {
...@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(...@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(
23 label: explicitLabel ?? atom.label?.[0] ?? "atom query",24 label: explicitLabel ?? atom.label?.[0] ?? "atom query",
24 reportFailure,25 reportFailure,
25 reportDefect,26 reportDefect,
27 refresh,
26 });28 });
27 },29 },
28 [explicitLabel, family, registry, reportDefect, reportFailure],30 [explicitLabel, family, registry, refresh, reportDefect, reportFailure],
29 );31 );
30}32}
apps/server/src/assets/AssetAccess.test.ts+335
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary.
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeFSP from "node:fs/promises";
2import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";5import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";6import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
4import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
...@@ -9,18 +12,28 @@ import * as Layer from "effect/Layer";...@@ -9,18 +12,28 @@ import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";12import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";13import * as PlatformError from "effect/PlatformError";
11import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
15import { HttpServerResponse } from "effect/unstable/http";
16import { vi } from "vite-plus/test";
1217
13import * as ServerSecretStore from "../auth/ServerSecretStore.ts";18import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
14import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
15import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";20import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
16import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";21import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
17import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import { assetFileResponse } from "../http.ts";
18import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";24import { 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
20const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {32const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
21 prefix: "t3-asset-access-test-",33 prefix: "t3-asset-access-test-",
22});34});
23const testLayer = Layer.mergeAll(35const testLayer = Layer.mergeAll(
36 NodeHttpPlatform.layer,
24 configLayer,37 configLayer,
25 WorkspacePaths.layer,38 WorkspacePaths.layer,
26 ProjectFaviconResolver.layer.pipe(39 ProjectFaviconResolver.layer.pipe(
...@@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll(...@@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll(
31).pipe(Layer.provideMerge(NodeServices.layer));44).pipe(Layer.provideMerge(NodeServices.layer));
3245
33describe("AssetAccess", () => {46describe("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
34 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>369 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
35 Effect.gen(function* () {370 Effect.gen(function* () {
36 const fileSystem = yield* FileSystem.FileSystem;371 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+84
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16import {16import {
17 isWorkspaceImagePreviewPath,17 isWorkspaceImagePreviewPath,
18 isWorkspacePreviewEntryPath,18 isWorkspacePreviewEntryPath,
19 mediaMimeTypeFromExtension,
19 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,20 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
20 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,21 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
21} from "@t3tools/shared/filePreview";22} from "@t3tools/shared/filePreview";
...@@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta...@@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta
41import * as ServerConfig from "../config.ts";42import * as ServerConfig from "../config.ts";
42import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";43import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
43import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";44import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
45import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
4446
45export const ASSET_ROUTE_PREFIX = "/api/assets";47export const ASSET_ROUTE_PREFIX = "/api/assets";
4648
...@@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([...@@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([
76 relativePath: Schema.String,78 relativePath: Schema.String,
77 expiresAt: Schema.Number,79 expiresAt: Schema.Number,
78 }),80 }),
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 }),
79 Schema.Struct({89 Schema.Struct({
80 version: Schema.Literal(1),90 version: Schema.Literal(1),
81 kind: Schema.Literal("attachment"),91 kind: Schema.Literal("attachment"),
...@@ -115,6 +125,7 @@ export type ResolvedAsset = {...@@ -115,6 +125,7 @@ export type ResolvedAsset = {
115 readonly download?: boolean;125 readonly download?: boolean;
116 readonly fileName?: string;126 readonly fileName?: string;
117 readonly mimeType?: string;127 readonly mimeType?: string;
128 readonly file?: OpenMediaFile;
118};129};
119130
120function decodeClaims(encodedPayload: string): AssetClaims | null {131function decodeClaims(encodedPayload: string): AssetClaims | null {
...@@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
211 let sourcePath: string | undefined;222 let sourcePath: string | undefined;
212223
213 switch (input.resource._tag) {224 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 }
214 case "workspace-file": {274 case "workspace-file": {
215 if (!input.workspaceRoot) {275 if (!input.workspaceRoot) {
216 return yield* new AssetWorkspaceContextNotFoundError({276 return yield* new AssetWorkspaceContextNotFoundError({
...@@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
526 const decodedPath = decodeRelativePath(relativePath);586 const decodedPath = decodeRelativePath(relativePath);
527 if (decodedPath === null) return null;587 if (decodedPath === null) return null;
528 const path = yield* Path.Path;588 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 }
529 if (claims.kind === "workspace-file-exact") {613 if (claims.kind === "workspace-file-exact") {
530 if (decodedPath !== path.basename(claims.relativePath)) return null;614 if (decodedPath !== path.basename(claims.relativePath)) return null;
531 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({615 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 @@...@@ -1,5 +1,5 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { describe } from "vite-plus/test";2import { describe, vi } from "vite-plus/test";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
...@@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";8import * as Path from "effect/Path";
9import { HttpServerResponse } from "effect/unstable/http";9import { HttpServerResponse } from "effect/unstable/http";
10import { openMediaFile } from "./assets/MediaFile.ts";
1011
11import {12import {
12 assetResponseHeaders,13 assetResponseHeaders,
...@@ -19,6 +20,179 @@ import {...@@ -19,6 +20,179 @@ import {
19const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);20const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
2021
21describe("video asset byte ranges", () => {22describe("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
22 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>196 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
23 Effect.gen(function* () {197 Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;198 const fs = yield* FileSystem.FileSystem;
apps/server/src/http.ts+46-10
...@@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability";...@@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability";
2929
30import * as ServerConfig from "./config.ts";30import * as ServerConfig from "./config.ts";
31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
32import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
32import {33import {
33 ATTACHMENT_UPLOAD_ROUTE_PREFIX,34 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
34 storeAttachmentUpload,35 storeAttachmentUpload,
...@@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) {...@@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) {
124 }125 }
125 const start = first ?? (last! >= size ? 0n : size - last!);126 const start = first ?? (last! >= size ? 0n : size - last!);
126 const end = first === null || last === null || last >= size ? size - 1n : last;127 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 }
127 return {131 return {
128 _tag: "Range" as const,132 _tag: "Range" as const,
129 offset: start,133 offset: start,
...@@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (...@@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
138 readonly download?: boolean;142 readonly download?: boolean;
139 readonly fileName?: string;143 readonly fileName?: string;
140 readonly mimeType?: string;144 readonly mimeType?: string;
145 readonly file?: OpenMediaFile;
141 },146 },
142 rangeHeader?: string,147 rangeHeader?: string,
143 ifRangeHeader?: string,148 ifRangeHeader?: string,
149 method: "GET" | "HEAD" = "GET",
144) {150) {
145 const headers = assetResponseHeaders(asset.path, asset);151 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) {
147 headers["Accept-Ranges"] = "bytes";164 headers["Accept-Ranges"] = "bytes";
148 // If-Range requires a matching validator. A full response is safe when we cannot validate it.165 // 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) {
150 const fs = yield* FileSystem.FileSystem;167 const fs = yield* FileSystem.FileSystem;
151 const info = yield* fs.stat(asset.path);168 const info = mediaInfo ?? (yield* fs.stat(asset.path));
152 const range = assetByteRange(rangeHeader, info.size);169 const range = assetByteRange(rangeHeader, info.size);
153 if (range?._tag === "Unsatisfiable") {170 if (range?._tag === "Unsatisfiable") {
154 return HttpServerResponse.empty({171 return HttpServerResponse.empty({
...@@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (...@@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
157 });174 });
158 }175 }
159 if (range?._tag === "Range") {176 if (range?._tag === "Range") {
160 return yield* HttpServerResponse.file(asset.path, {177 status = 206;
161 status: 206,178 offset = range.offset;
162 offset: range.offset,179 bytesToRead = range.bytesToRead;
163 bytesToRead: range.bytesToRead,180 headers["Content-Range"] = range.contentRange;
164 headers: { ...headers, "Content-Range": range.contentRange },
165 });
166 }181 }
167 }182 }
168 }183 }
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 });
170});205});
171206
172export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {207export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
...@@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add(...@@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add(
338 asset,373 asset,
339 request.method === "GET" ? request.headers.range : undefined,374 request.method === "GET" ? request.headers.range : undefined,
340 request.headers["if-range"],375 request.headers["if-range"],
376 request.method === "HEAD" ? "HEAD" : "GET",
341 ).pipe(377 ).pipe(
342 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),378 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
343 );379 );
apps/web/src/assets/assetUrls.ts+18-1
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";2import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";4import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
4import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
5import { useMemo } from "react";6import { useCallback, useMemo } from "react";
67
7import { assetEnvironment } from "~/state/assets";8import { assetEnvironment } from "~/state/assets";
8import { usePreparedConnection } from "~/state/session";9import { usePreparedConnection } from "~/state/session";
10import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
911
10export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";12export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1113
...@@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc...@@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc
49 return result.url;51 return result.url;
50}52}
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
52export function useAssetUrls(69export function useAssetUrls(
53 environmentId: EnvironmentId,70 environmentId: EnvironmentId,
54 resources: ReadonlyArray<AssetResource>,71 resources: ReadonlyArray<AssetResource>,
apps/web/src/components/ChatMarkdown.tsx+357-60
...@@ -42,6 +42,9 @@ import {...@@ -42,6 +42,9 @@ import {
42 classifyMarkdownImageSource,42 classifyMarkdownImageSource,
43 markdownImageSourceFragment,43 markdownImageSourceFragment,
44} from "@t3tools/client-runtime/markdown-images";44} 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";
45import * as Cause from "effect/Cause";48import * as Cause from "effect/Cause";
46import { AsyncResult } from "effect/unstable/reactivity";49import { AsyncResult } from "effect/unstable/reactivity";
47import React, {50import React, {
...@@ -76,7 +79,14 @@ import {...@@ -76,7 +79,14 @@ import {
76 renderCodexFileCitationsAsMarkdown,79 renderCodexFileCitationsAsMarkdown,
77} from "@t3tools/client-runtime/codex-markdown-directives";80} from "@t3tools/client-runtime/codex-markdown-directives";
78import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";81import { 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";
80import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";90import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
81import { PierreEntryIcon } from "./chat/PierreEntryIcon";91import { PierreEntryIcon } from "./chat/PierreEntryIcon";
82import {92import {
...@@ -126,7 +136,7 @@ import {...@@ -126,7 +136,7 @@ import {
126 type MarkdownFileLinkMeta,136 type MarkdownFileLinkMeta,
127} from "../markdown-links";137} from "../markdown-links";
128import { readLocalApi } from "../localApi";138import { readLocalApi } from "../localApi";
129import { useAssetUrlState } from "../assets/assetUrls";139import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
130import { cn } from "../lib/utils";140import { cn } from "../lib/utils";
131import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";141import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
132import { useRightPanelStore } from "../rightPanelStore";142import { useRightPanelStore } from "../rightPanelStore";
...@@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: {...@@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: {
195 canOpenInEditor: boolean;205 canOpenInEditor: boolean;
196 canOpenInBrowser: boolean;206 canOpenInBrowser: boolean;
197 canOpenInPanel: boolean;207 canOpenInPanel: boolean;
208 canOpenMedia?: boolean;
198}): boolean {209}): 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 );
200}216}
201217
202export function shouldUseMarkdownFileBrowserPrimaryAction(input: {218export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
...@@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps {...@@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps {
1011 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;1027 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
1012 openInEditorMenuLabel: string;1028 openInEditorMenuLabel: string;
1013 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;1029 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1030 onOpenMedia?: (() => void) | undefined;
1014 onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;1031 onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1015 /** Platform-specific menu label ("Reveal in Finder", ...); required for the1032 /** Platform-specific menu label ("Reveal in Finder", ...); required for the
1016 reveal item to show. */1033 reveal item to show. */
...@@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:...@@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
1133 );1150 );
1134});1151});
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";
1137const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn(1160const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn(
1138 "h-auto w-auto object-contain",1161 "h-auto w-auto object-contain",
1139 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,1162 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1140);1163);
11411164
1142function markdownImageCopy(alt: string, src: string, title: string | undefined): string {1165function markdownImageCopy(alt: string, src: string, title: string | undefined): string {
...@@ -1167,11 +1190,10 @@ function authoredImageSizeStyle(...@@ -1167,11 +1190,10 @@ function authoredImageSizeStyle(
1167 return undefined;1190 return undefined;
1168}1191}
11691192
1170const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!";
1171const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(1193const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1172 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,1194 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1173 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,1195 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1174 "rounded-lg border border-border/40",1196 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1175);1197);
1176const MarkdownLinkContext = React.createContext(false);1198const MarkdownLinkContext = React.createContext(false);
11771199
...@@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps(...@@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps(
1179 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,1201 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
1180 src: string,1202 src: string,
1181 alt: string,1203 alt: string,
1204 originalUrl?: string,
1205 actionsSource?: MediaActionSource,
1182) {1206) {
1183 if (!onImageExpand) return {};1207 if (!onImageExpand) return {};
1184 const previewName = alt.trim() || "image";1208 const previewName = alt.trim() || "image";
...@@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps(...@@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps(
1186 if (event.currentTarget.closest("a")) return;1210 if (event.currentTarget.closest("a")) return;
1187 event.preventDefault();1211 event.preventDefault();
1188 event.stopPropagation();1212 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 });
1190 };1224 };
1191 return {1225 return {
1192 role: "button" as const,1226 role: "button" as const,
...@@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps(...@@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps(
1202function ChatMarkdownImageFallback(props: {1236function ChatMarkdownImageFallback(props: {
1203 readonly alt: string;1237 readonly alt: string;
1204 readonly copyMarkdown?: string | undefined;1238 readonly copyMarkdown?: string | undefined;
1239 readonly kind?: "image" | "video";
1240 readonly actionsSource?: MediaActionSource;
1205}) {1241}) {
1206 return (1242 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1243 const content = (
1207 <span1244 <span
1208 data-markdown-copy={props.copyMarkdown}1245 data-markdown-copy={props.copyMarkdown}
1209 className={cn(1246 className={cn(
1210 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,1247 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1211 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",1248 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
1212 )}1249 )}
1213 >1250 >
1214 <span className="inline-flex items-center gap-1.5">1251 <span className="inline-flex items-center gap-1.5">
1215 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />1252 <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}
1217 </span>1254 </span>
1218 </span>1255 </span>
1219 );1256 );
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 );
1220}1319}
12211320
1222/** Environment-hosted images load through a signed asset URL. */1321/** Environment-hosted media loads through an exact-file signed asset URL. */
1223export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {1322export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
1224 readonly environmentId: EnvironmentId;1323 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";
1226 readonly alt: string;1329 readonly alt: string;
1227 readonly copyMarkdown?: string;1330 readonly copyMarkdown?: string;
1228 readonly srcFragment?: string;1331 readonly srcFragment?: string;
1229 readonly style?: CSSProperties | undefined;1332 readonly style?: CSSProperties | undefined;
1333 readonly workspaceRoot?: string | undefined;
1230 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;1334 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1231}) {1335}) {
1232 const assetUrl = useAssetUrlState(props.environmentId, props.resource);1336 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
1337 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
1233 const [failedUrl, setFailedUrl] = useState<string | null>(null);1338 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
1235 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {1384 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 );
1237 }1393 }
1238 if (assetUrl._tag !== "Success") {1394 if (assetUrl._tag !== "Success") {
1239 return (1395 return (
1240 <span1396 <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}
1241 data-markdown-copy={props.copyMarkdown}1416 data-markdown-copy={props.copyMarkdown}
1242 role="status"1417 loading="lazy"
1243 aria-label="Loading image"1418 draggable={false}
1244 className={cn(1419 className={cn(
1245 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,1420 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1246 "aspect-video w-64 max-w-full rounded-lg bg-muted/60",1421 props.onImageExpand && "cursor-zoom-in",
1247 CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME,
1248 )}1422 )}
1249 style={props.style}1423 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)}
1250 />1432 />
1251 );1433 </MediaActions>
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 />
1269 );1434 );
1270});1435});
12711436
...@@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1448 onOpenInPanel,1613 onOpenInPanel,
1449 openInEditorMenuLabel,1614 openInEditorMenuLabel,
1450 onOpenInBrowser,1615 onOpenInBrowser,
1616 onOpenMedia,
1451 onReveal,1617 onReveal,
1452 revealLabel,1618 revealLabel,
1453 className,1619 className,
...@@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1491 }, [onOpen, targetPath]);1657 }, [onOpen, targetPath]);
14921658
1493 const handleOpenInFilePreview = useCallback(() => {1659 const handleOpenInFilePreview = useCallback(() => {
1494 if (!threadRef || !workspaceRelativePath) {1660 if (threadRef && workspaceRelativePath) {
1495 handleOpenInEditor();1661 onOpenInPanel(workspaceRelativePath, line);
1496 return;1662 return;
1497 }1663 }
1498 onOpenInPanel(workspaceRelativePath, line);1664 if (onOpenMedia) {
1499 }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]);1665 onOpenMedia();
1666 return;
1667 }
1668 handleOpenInEditor();
1669 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);
15001670
1501 const handleOpenInBrowser = useCallback(() => {1671 const handleOpenInBrowser = useCallback(() => {
1502 if (!onOpenInBrowser) {1672 if (!onOpenInBrowser) {
...@@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1621 try {1791 try {
1622 const clicked = await api.contextMenu.show(1792 const clicked = await api.contextMenu.show(
1623 [1793 [
1794 ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []),
1624 ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []),1795 ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []),
1625 ...(onOpenInBrowser1796 ...(onOpenInBrowser
1626 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)1797 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
...@@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1632 position,1803 position,
1633 );1804 );
16341805
1806 if (clicked === "preview-media") {
1807 onOpenMedia?.();
1808 return;
1809 }
1635 if (clicked === "open") {1810 if (clicked === "open") {
1636 handleOpenInEditor();1811 handleOpenInEditor();
1637 return;1812 return;
...@@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1665 handleOpenInEditor,1840 handleOpenInEditor,
1666 handleRevealInFileManager,1841 handleRevealInFileManager,
1667 onOpenInBrowser,1842 onOpenInBrowser,
1843 onOpenMedia,
1668 onOpen,1844 onOpen,
1669 onReveal,1845 onReveal,
1670 openInEditorMenuLabel,1846 openInEditorMenuLabel,
...@@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1696 canOpenInEditor,1872 canOpenInEditor,
1697 canOpenInBrowser,1873 canOpenInBrowser,
1698 canOpenInPanel,1874 canOpenInPanel,
1875 canOpenMedia: onOpenMedia !== undefined,
1699 });1876 });
1700 const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({1877 const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({
1701 iconPath,1878 iconPath,
...@@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual(
1787 previous.onOpenInPanel === next.onOpenInPanel &&1964 previous.onOpenInPanel === next.onOpenInPanel &&
1788 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&1965 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&
1789 previous.onOpenInBrowser === next.onOpenInBrowser &&1966 previous.onOpenInBrowser === next.onOpenInBrowser &&
1967 previous.onOpenMedia === next.onOpenMedia &&
1790 previous.onReveal === next.onReveal &&1968 previous.onReveal === next.onReveal &&
1791 previous.revealLabel === next.revealLabel &&1969 previous.revealLabel === next.revealLabel &&
1792 previous.className === next.className1970 previous.className === next.className
...@@ -1810,8 +1988,18 @@ function ChatMarkdown({...@@ -1810,8 +1988,18 @@ function ChatMarkdown({
1810 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,1988 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
1811}: ChatMarkdownProps) {1989}: ChatMarkdownProps) {
1812 const { resolvedTheme } = useTheme();1990 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]);
1813 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {2000 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1814 reportFailure: false,2001 reportFailure: false,
2002 refresh: true,
1815 });2003 });
1816 const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {2004 const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
1817 reportFailure: false,2005 reportFailure: false,
...@@ -1830,6 +2018,41 @@ function ChatMarkdown({...@@ -1830,6 +2018,41 @@ function ChatMarkdown({
1830 remoteOpen.isResolved,2018 remoteOpen.isResolved,
1831 );2019 );
1832 const preparedConnection = usePreparedConnection(environmentId);2020 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 );
1833 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));2056 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
1834 const threadServerConfig = useAtomValue(2057 const threadServerConfig = useAtomValue(
1835 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),2058 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
...@@ -2068,6 +2291,7 @@ function ChatMarkdown({...@@ -2068,6 +2291,7 @@ function ChatMarkdown({
2068 fileLinkMeta: MarkdownFileLinkMeta,2291 fileLinkMeta: MarkdownFileLinkMeta,
2069 copyMarkdown: string,2292 copyMarkdown: string,
2070 className?: string,2293 className?: string,
2294 mediaSource?: string,
2071 ) => {2295 ) => {
2072 const parentSuffix = fileLinkParentSuffixByPath.get(2296 const parentSuffix = fileLinkParentSuffixByPath.get(
2073 fileLinkMeta.filePath.replaceAll("\\", "/"),2297 fileLinkMeta.filePath.replaceAll("\\", "/"),
...@@ -2081,6 +2305,11 @@ function ChatMarkdown({...@@ -2081,6 +2305,11 @@ function ChatMarkdown({
2081 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,2305 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
2082 );2306 );
2083 }2307 }
2308 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
2309 const canPreviewMedia =
2310 mediaMimeTypeFromExtension(
2311 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
2312 ) !== null;
20842313
2085 return (2314 return (
2086 <MarkdownFileLink2315 <MarkdownFileLink
...@@ -2096,6 +2325,11 @@ function ChatMarkdown({...@@ -2096,6 +2325,11 @@ function ChatMarkdown({
2096 threadRef={threadRef}2325 threadRef={threadRef}
2097 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}2326 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
2098 onOpenInPanel={openFileInPanel}2327 onOpenInPanel={openFileInPanel}
2328 onOpenMedia={
2329 threadRef && canPreviewMedia
2330 ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath)
2331 : undefined
2332 }
2099 openInEditorMenuLabel={preferredEditorMenuLabel}2333 openInEditorMenuLabel={preferredEditorMenuLabel}
2100 onReveal={2334 onReveal={
2101 canUseShellActions && revealInFileManagerLabel !== undefined2335 canUseShellActions && revealInFileManagerLabel !== undefined
...@@ -2232,6 +2466,21 @@ function ChatMarkdown({...@@ -2232,6 +2466,21 @@ function ChatMarkdown({
2232 handleMarkdownFragmentClick(event, href);2466 handleMarkdownFragmentClick(event, href);
2233 return;2467 return;
2234 }2468 }
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 }
2235 // A link to a change request in a workspace project opens beside the2484 // A link to a change request in a workspace project opens beside the
2236 // conversation instead of in a browser: it is the thing being talked about, and2485 // conversation instead of in a browser: it is the thing being talked about, and
2237 // the panel it opens offers the browser as one of its actions. Anything else is2486 // the panel it opens offers the browser as one of its actions. Anything else is
...@@ -2323,6 +2572,7 @@ function ChatMarkdown({...@@ -2323,6 +2572,7 @@ function ChatMarkdown({
2323 fileLinkMeta,2572 fileLinkMeta,
2324 `[${fileLinkMeta.basename}](${normalizedHref})`,2573 `[${fileLinkMeta.basename}](${normalizedHref})`,
2325 props.className,2574 props.className,
2575 normalizedHref,
2326 );2576 );
2327 },2577 },
2328 code({ node, children, className, ...props }) {2578 code({ node, children, className, ...props }) {
...@@ -2332,7 +2582,12 @@ function ChatMarkdown({...@@ -2332,7 +2582,12 @@ function ChatMarkdown({
2332 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??2582 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2333 resolveInlineCodeFileLinkMeta(codeText, cwd);2583 resolveInlineCodeFileLinkMeta(codeText, cwd);
2334 if (fileLinkMeta) {2584 if (fileLinkMeta) {
2335 return fileLinkChip(fileLinkMeta, `\`${codeText}\``);2585 return fileLinkChip(
2586 fileLinkMeta,
2587 `\`${codeText}\``,
2588 undefined,
2589 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
2590 );
2336 }2591 }
2337 }2592 }
2338 return (2593 return (
...@@ -2342,7 +2597,7 @@ function ChatMarkdown({...@@ -2342,7 +2597,7 @@ function ChatMarkdown({
2342 );2597 );
2343 },2598 },
2344 img: function MarkdownImage({ node, title, src, alt, ...props }) {2599 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2345 const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand;2600 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
2346 const localSrc = node?.properties?.dataLocalSrc;2601 const localSrc = node?.properties?.dataLocalSrc;
2347 const markdownTitle = node?.properties?.dataMarkdownTitle;2602 const markdownTitle = node?.properties?.dataMarkdownTitle;
2348 const authoredSrc = typeof localSrc === "string" ? localSrc : src;2603 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
...@@ -2355,21 +2610,53 @@ function ChatMarkdown({...@@ -2355,21 +2610,53 @@ function ChatMarkdown({
2355 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);2610 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
2356 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);2611 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
2357 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);2612 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2613 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
2358 if (imageSource._tag === "Direct") {2614 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 }
2359 return (2638 return (
2360 <img2639 <MediaActions source={actionsSource}>
2361 {...props}2640 <img
2362 src={imageSource.uri}2641 {...props}
2363 alt={altText}2642 src={mediaSrc}
2364 loading="lazy"2643 alt={altText}
2365 className={cn(2644 loading="lazy"
2366 props.className,2645 className={cn(
2367 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,2646 props.className,
2368 imageExpand && "cursor-zoom-in",2647 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2369 )}2648 imageExpand && "cursor-zoom-in",
2370 style={authoredSizeStyle}2649 )}
2371 {...expandableMarkdownImageProps(imageExpand, imageSource.uri, altText)}2650 style={authoredSizeStyle}
2372 />2651 {...expandableMarkdownImageProps(
2652 imageExpand,
2653 mediaSrc,
2654 altText,
2655 originalUrl,
2656 actionsSource,
2657 )}
2658 />
2659 </MediaActions>
2373 );2660 );
2374 }2661 }
2375 if (imageSource._tag === "WorkspaceFile" && threadRef) {2662 if (imageSource._tag === "WorkspaceFile" && threadRef) {
...@@ -2377,19 +2664,21 @@ function ChatMarkdown({...@@ -2377,19 +2664,21 @@ function ChatMarkdown({
2377 <ChatMarkdownAssetImage2664 <ChatMarkdownAssetImage
2378 environmentId={threadRef.environmentId}2665 environmentId={threadRef.environmentId}
2379 resource={{2666 resource={{
2380 _tag: "workspace-file",2667 _tag: "media-file",
2381 threadId: threadRef.threadId,2668 threadId: threadRef.threadId,
2382 path: imageSource.path,2669 path: imageSource.path,
2383 }}2670 }}
2384 alt={altText}2671 alt={altText}
2672 kind={kind}
2385 copyMarkdown={copyMarkdown}2673 copyMarkdown={copyMarkdown}
2386 srcFragment={markdownImageSourceFragment(classifiedSrc)}2674 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2387 style={authoredSizeStyle}2675 style={authoredSizeStyle}
2676 workspaceRoot={cwd}
2388 onImageExpand={imageExpand}2677 onImageExpand={imageExpand}
2389 />2678 />
2390 );2679 );
2391 }2680 }
2392 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} />;2681 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
2393 },2682 },
2394 table({ node: _node, ...props }) {2683 table({ node: _node, ...props }) {
2395 return <MarkdownTable {...props} />;2684 return <MarkdownTable {...props} />;
...@@ -2438,6 +2727,8 @@ function ChatMarkdown({...@@ -2438,6 +2727,8 @@ function ChatMarkdown({
2438 onTaskListChange,2727 onTaskListChange,
2439 onUseArtifactTemplate,2728 onUseArtifactTemplate,
2440 onImageExpand,2729 onImageExpand,
2730 expandMedia,
2731 openMarkdownMedia,
2441 openFileInPanel,2732 openFileInPanel,
2442 openInPreferredEditor,2733 openInPreferredEditor,
2443 openChangeRequestLink,2734 openChangeRequestLink,
...@@ -2483,6 +2774,12 @@ function ChatMarkdown({...@@ -2483,6 +2774,12 @@ function ChatMarkdown({
2483 >2774 >
2484 {text}2775 {text}
2485 </ReactMarkdown>2776 </ReactMarkdown>
2777 {localMediaPreview ? (
2778 <ExpandedImageDialog
2779 preview={localMediaPreview}
2780 onClose={() => setLocalMediaPreview(null)}
2781 />
2782 ) : null}
2486 </div>2783 </div>
2487 );2784 );
2488}2785}
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+7-6
...@@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({...@@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({
99
10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
11vi.mock("../assets/assetUrls", () => ({11vi.mock("../assets/assetUrls", () => ({
12 useAssetUrlRefresh: () => vi.fn(),
12 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {13 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
13 testState.resources.push(resource);14 testState.resources.push(resource);
14 if (testState.assetState === "loading") return { _tag: "Loading" };15 if (testState.assetState === "loading") return { _tag: "Loading" };
...@@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => {...@@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => {
106107
107 expect(testState.resources).toEqual([108 expect(testState.resources).toEqual([
108 {109 {
109 _tag: "workspace-file",110 _tag: "media-file",
110 threadId: threadRef.threadId,111 threadId: threadRef.threadId,
111 path: expectedPath,112 path: expectedPath,
112 },113 },
...@@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => {...@@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => {
126127
127 expect(testState.resources).toEqual([128 expect(testState.resources).toEqual([
128 {129 {
129 _tag: "workspace-file",130 _tag: "media-file",
130 threadId: threadRef.threadId,131 threadId: threadRef.threadId,
131 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",132 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
132 },133 },
133 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },134 { _tag: "media-file", threadId: threadRef.threadId, path: imagePath },
134 { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },135 { _tag: "media-file", threadId: threadRef.threadId, path: imagePath },
135 {136 {
136 _tag: "workspace-file",137 _tag: "media-file",
137 threadId: threadRef.threadId,138 threadId: threadRef.threadId,
138 path: "\\\\server\\share\\workspace-image.svg",139 path: "\\\\server\\share\\workspace-image.svg",
139 },140 },
...@@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => {...@@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => {
149150
150 expect(testState.resources).toEqual([151 expect(testState.resources).toEqual([
151 {152 {
152 _tag: "workspace-file",153 _tag: "media-file",
153 threadId: threadRef.threadId,154 threadId: threadRef.threadId,
154 path: "D:/screens/workspace-image.svg",155 path: "D:/screens/workspace-image.svg",
155 },156 },
apps/web/src/components/ChatView.logic.test.ts-18
...@@ -22,7 +22,6 @@ import {...@@ -22,7 +22,6 @@ import {
22 dismissBranchMismatchForSession,22 dismissBranchMismatchForSession,
23 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,23 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
24 getStartedThreadModelChangeBlockReason,24 getStartedThreadModelChangeBlockReason,
25 loadVideoPreviewUrl,
26 isVideoPreviewRequestCurrent,25 isVideoPreviewRequestCurrent,
27 hasEnvironmentReconnectWarningGraceElapsed,26 hasEnvironmentReconnectWarningGraceElapsed,
28 hasServerAcknowledgedLocalDispatch,27 hasServerAcknowledgedLocalDispatch,
...@@ -44,23 +43,6 @@ import {...@@ -44,23 +43,6 @@ import {
44 shouldWriteThreadErrorToCurrentServerThread,43 shouldWriteThreadErrorToCurrentServerThread,
45} from "./ChatView.logic";44} 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
64describe("isVideoPreviewRequestCurrent", () => {46describe("isVideoPreviewRequestCurrent", () => {
65 it("rejects changed threads and replaced previews", () => {47 it("rejects changed threads and replaced previews", () => {
66 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);48 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
apps/web/src/components/ChatView.logic.ts+35-4
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import {1import {
2 type AssetCreateUrlInput,
3 type AssetCreateUrlResult,
4 type ChatFileAttachment,
2 type EnvironmentId,5 type EnvironmentId,
3 isProviderDriverKind,6 isProviderDriverKind,
4 ProjectId,7 ProjectId,
...@@ -12,6 +15,12 @@ import {...@@ -12,6 +15,12 @@ import {
12 type ThreadId,15 type ThreadId,
13 type TurnId,16 type TurnId,
14} from "@t3tools/contracts";17} 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";
15import {24import {
16 appendCodexArtifactTemplateUsePrompt,25 appendCodexArtifactTemplateUsePrompt,
17 codexArtifactTemplateUsePrompt,26 codexArtifactTemplateUsePrompt,
...@@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {...@@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
299 URL.revokeObjectURL(previewUrl);308 URL.revokeObjectURL(previewUrl);
300}309}
301310
302export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise<string> {311/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */
303 const response = await fetch(url, signal ? { signal } : {});312export async function resolveFileAttachmentUrl(input: {
304 if (!response.ok) throw new Error(`Could not load video (${response.status}).`);313 attachment: ChatFileAttachment;
305 return URL.createObjectURL(await response.blob());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;
306}337}
307338
308export function isVideoPreviewRequestCurrent(339export function isVideoPreviewRequestCurrent(
apps/web/src/components/ChatView.tsx+27-73
...@@ -365,7 +365,7 @@ import {...@@ -365,7 +365,7 @@ import {
365 cloneComposerImageForRetry,365 cloneComposerImageForRetry,
366 deriveLockedProvider,366 deriveLockedProvider,
367 readFileAsDataUrl,367 readFileAsDataUrl,
368 loadVideoPreviewUrl,368 resolveFileAttachmentUrl,
369 isVideoPreviewRequestCurrent,369 isVideoPreviewRequestCurrent,
370 reconcileMountedTerminalThreadIds,370 reconcileMountedTerminalThreadIds,
371 resolveBackgroundDraftWorkspaceOptions,371 resolveBackgroundDraftWorkspaceOptions,
...@@ -424,7 +424,7 @@ import {...@@ -424,7 +424,7 @@ import {
424 resolveServerSelfUpdateCapability,424 resolveServerSelfUpdateCapability,
425 serverUpdateGuidance,425 serverUpdateGuidance,
426} from "../versionSkew";426} from "../versionSkew";
427import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls";427import { useAssetUrls } from "../assets/assetUrls";
428428
429const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =429const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
430 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";430 "[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) {...@@ -1331,6 +1331,7 @@ function ChatViewContent(props: ChatViewProps) {
1331 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });1331 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1332 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1332 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1333 reportFailure: false,1333 reportFailure: false,
1334 refresh: true,
1334 });1335 });
1335 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {1336 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
1336 reportFailure: false,1337 reportFailure: false,
...@@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) {
1459 const routeThreadKeyRef = useRef(routeThreadKey);1460 const routeThreadKeyRef = useRef(routeThreadKey);
1460 routeThreadKeyRef.current = routeThreadKey;1461 routeThreadKeyRef.current = routeThreadKey;
1461 const videoPreviewRequestIdRef = useRef(0);1462 const videoPreviewRequestIdRef = useRef(0);
1462 const videoPreviewAbortControllerRef = useRef<AbortController | null>(null);
1463 const cancelVideoPreviewRequest = useCallback(() => {1463 const cancelVideoPreviewRequest = useCallback(() => {
1464 videoPreviewRequestIdRef.current += 1;1464 videoPreviewRequestIdRef.current += 1;
1465 videoPreviewAbortControllerRef.current?.abort();
1466 videoPreviewAbortControllerRef.current = null;
1467 }, []);1465 }, []);
1468 const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);1466 const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);
1469 const [showScrollToBottom, setShowScrollToBottom] = useState(false);1467 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
...@@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) {
2542 toastManager.add({ type: "error", title: "The environment is not connected." });2540 toastManager.add({ type: "error", title: "The environment is not connected." });
2543 return;2541 return;
2544 }2542 }
2545 const videoMime = videoMimeType(attachment);2543 const isVideo = videoMimeType(attachment) !== null;
2546 const isVideo = videoMime !== null;
2547 const action = isVideo ? "play" : "download";2544 const action = isVideo ? "play" : "download";
2548 const videoPreviewAbortController = isVideo ? new AbortController() : null;
2549 if (isVideo) {
2550 videoPreviewAbortControllerRef.current?.abort();
2551 videoPreviewAbortControllerRef.current = videoPreviewAbortController;
2552 }
2553 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;2545 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
2554 const isCurrentRequest = () =>2546 const isCurrentRequest = () =>
2555 !isVideo ||2547 !isVideo ||
...@@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) {
2559 videoPreviewRequestId,2551 videoPreviewRequestId,
2560 videoPreviewRequestIdRef.current,2552 videoPreviewRequestIdRef.current,
2561 );2553 );
2562 const finishVideoPreviewRequest = () => {
2563 if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
2564 setOpeningVideoAttachmentId(null);
2565 videoPreviewAbortControllerRef.current = null;
2566 }
2567 };
2568 if (isVideo) setOpeningVideoAttachmentId(attachment.id);2554 if (isVideo) setOpeningVideoAttachmentId(attachment.id);
25692555
2570 // fileName and mimeType ride in the signed claims so videos render2556 try {
2571 // inline while other files keep their real download name and type.2557 const url = await resolveFileAttachmentUrl({
2572 const result = await createAttachmentAssetUrl({2558 attachment,
2573 environmentId,2559 environmentId,
2574 input: {2560 httpBaseUrl: connection.httpBaseUrl,
2575 resource: {2561 createAssetUrl: createAttachmentAssetUrl,
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.",
2594 });2562 });
2595 return;2563 if (!isCurrentRequest()) return;
2596 }2564 if (isVideo) {
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 }
2611 setExpandedImage({2565 setExpandedImage({
2612 images: [{ src: previewUrl, name: attachment.name, type: "video" }],2566 images: [{ src: url, name: attachment.name, type: "video" }],
2613 index: 0,2567 index: 0,
2614 });2568 });
2615 } catch (error) {2569 return;
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();
2624 }2570 }
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);
2626 }2584 }
2627 const anchor = document.createElement("a");
2628 anchor.href = url;
2629 anchor.download = attachment.name;
2630 anchor.click();
2631 },2585 },
2632 [createAttachmentAssetUrl, environmentId, routeThreadKey],2586 [createAttachmentAssetUrl, environmentId, routeThreadKey],
2633 );2587 );
apps/web/src/components/chat/ChatComposer.tsx+2-3
...@@ -172,6 +172,7 @@ import {...@@ -172,6 +172,7 @@ import {
172 submitComposerDraft,172 submitComposerDraft,
173} from "./composerSubmission";173} from "./composerSubmission";
174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175176
176function ComposerVideoThumbnail({ file }: { file: File }) {177function ComposerVideoThumbnail({ file }: { file: File }) {
177 const setVideo = useCallback(178 const setVideo = useCallback(
...@@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) {...@@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) {
189 playsInline190 playsInline
190 preload="metadata"191 preload="metadata"
191 aria-hidden="true"192 aria-hidden="true"
192 onLoadedMetadata={(event) => {193 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
193 event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0);
194 }}
195 className="pointer-events-none absolute inset-0 size-full object-cover"194 className="pointer-events-none absolute inset-0 size-full object-cover"
196 />195 />
197 );196 );
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 @@...@@ -1,41 +1,64 @@
1import { memo, useCallback, useEffect, useState } from "react";1import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
2import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react";2import { createPortal } from "react-dom";
3import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";4import { 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
6interface ExpandedImageDialogProps {12interface ExpandedImageDialogProps {
7 preview: ExpandedImagePreview;13 preview: ExpandedImagePreview;
8 onClose: () => void;14 onClose: () => void;
9}15}
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
11export const ExpandedImageDialog = memo(function ExpandedImageDialog({28export const ExpandedImageDialog = memo(function ExpandedImageDialog({
12 preview,29 preview,
13 onClose,30 onClose,
14}: ExpandedImageDialogProps) {31}: ExpandedImageDialogProps) {
15 const [imageOffset, setImageOffset] = useState(0);32 const [imageOffset, setImageOffset] = useState(0);
16 const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null);33 const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null);
17 const [downloadingVideoSrc, setDownloadingVideoSrc] = useState<string | null>(null);34 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
18 const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState<string | null>(null);
19 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;35 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
21 const navigateImage = useCallback((direction: -1 | 1) => {53 const navigateImage = useCallback((direction: -1 | 1) => {
22 setImageOffset((current) => current + direction);54 setImageOffset((current) => current + direction);
23 }, []);55 }, []);
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
37 useEffect(() => {57 useEffect(() => {
38 const onKeyDown = (event: globalThis.KeyboardEvent) => {58 const onKeyDown = (event: globalThis.KeyboardEvent) => {
59 if (event.defaultPrevented || isContextMenuOpen()) {
60 return;
61 }
39 if (event.key === "Escape") {62 if (event.key === "Escape") {
40 event.preventDefault();63 event.preventDefault();
41 event.stopPropagation();64 event.stopPropagation();
...@@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
58 return () => window.removeEventListener("keydown", onKeyDown);81 return () => window.removeEventListener("keydown", onKeyDown);
59 }, [navigateImage, onClose, preview.images.length]);82 }, [navigateImage, onClose, preview.images.length]);
6083
61 const item = preview.images[index];
62 if (!item) return null;84 if (!item) return null;
63 const mediaLabel = item.type === "video" ? "video" : "image";85 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;91 return createPortal(
66 const videoDownloadFailed = downloadFailedVideoSrc === item.src;
67 return (
68 <div92 <div
69 className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"93 className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
70 role="dialog"94 role="dialog"
...@@ -89,61 +113,59 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -89,61 +113,59 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
89 <ChevronLeftIcon className="size-5" />113 <ChevronLeftIcon className="size-5" />
90 </Button>114 </Button>
91 )}115 )}
92 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">116 <MediaActions source={actionsSource}>
93 <Button117 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
94 type="button"118 <Button
95 size="icon-xs"119 type="button"
96 variant="ghost"120 size="icon-xs"
97 className="absolute right-2 top-2"121 variant="ghost"
98 onClick={onClose}122 className="absolute right-2 top-2 z-20"
99 aria-label={`Close ${mediaLabel} preview`}123 onClick={onClose}
100 >124 aria-label={`Close ${mediaLabel} preview`}
101 <XIcon />125 >
102 </Button>126 <XIcon />
103 {item.type === "video" && failedVideoSrc === item.src ? (127 </Button>
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">128 {item.type === "video" && failedVideoSrc === item.src ? (
105 <p className="text-sm">129 <ExpandedMediaFailure>
106 {videoDownloadFailed130 <p>This video could not be loaded or played.</p>
107 ? "Could not download this video."131 <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} />
108 : "This video format cannot be played here."}132 </ExpandedMediaFailure>
109 </p>133 ) : item.type === "video" ? (
110 <Button134 <video
111 size="sm"135 src={item.src}
112 variant="secondary"136 aria-label={item.name}
113 aria-busy={isDownloadingVideo || undefined}137 autoPlay={item.autoPlay ?? true}
114 aria-disabled={isDownloadingVideo || undefined}138 preload="metadata"
115 onClick={() => {139 controls
116 if (isDownloadingVideo) return;140 playsInline
117 void downloadVideo(item.src, item.name);141 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
118 }}142 onError={() => setFailedVideoSrc(item.src)}
119 >143 className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl"
120 <DownloadIcon />144 />
121 {isDownloadingVideo ? "Downloading…" : "Download video"}145 ) : failedImageSrc === item.src ? (
122 </Button>146 <ExpandedMediaFailure>
123 </div>147 <p>
124 ) : item.type === "video" ? (148 {openOriginalLink
125 <video149 ? "This image could not be loaded."
126 src={item.src}150 : "Image unavailable. The file may have been moved or deleted."}
127 aria-label={item.name}151 </p>
128 autoPlay152 {openOriginalLink}
129 controls153 </ExpandedMediaFailure>
130 playsInline154 ) : (
131 onError={() => setFailedVideoSrc(item.src)}155 <img
132 className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl"156 src={item.src}
133 />157 alt={item.name}
134 ) : (158 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
135 <img159 draggable={false}
136 src={item.src}160 onError={() => setFailedImageSrc(item.src)}
137 alt={item.name}161 />
138 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"162 )}
139 draggable={false}163 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
140 />164 {item.name}
141 )}165 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
142 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">166 </p>
143 {item.name}167 </div>
144 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}168 </MediaActions>
145 </p>
146 </div>
147 {preview.images.length > 1 && (169 {preview.images.length > 1 && (
148 <Button170 <Button
149 type="button"171 type="button"
...@@ -156,6 +178,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -156,6 +178,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
156 <ChevronRightIcon className="size-5" />178 <ChevronRightIcon className="size-5" />
157 </Button>179 </Button>
158 )}180 )}
159 </div>181 </div>,
182 document.body,
160 );183 );
161});184});
apps/web/src/components/chat/ExpandedImagePreview.test.ts+36-27
...@@ -1,12 +1,46 @@...@@ -1,12 +1,46 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
3import type { ComposerFileAttachment } from "../../composerDraftStore";3import type { ComposerFileAttachment } from "../../composerDraftStore";
4import {4import {
5 attachVideoThumbnail,5 attachVideoThumbnail,
6 buildExpandedImagePreview,6 buildExpandedImagePreview,
7 downloadVideoPreview,7 resolveMarkdownMediaPreview,
8} from "./ExpandedImagePreview";8} 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
10describe("buildExpandedImagePreview", () => {44describe("buildExpandedImagePreview", () => {
11 it("builds a video preview for a local video attachment", () => {45 it("builds a video preview for a local video attachment", () => {
12 const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" });46 const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" });
...@@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => {...@@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => {
40 detach();74 detach();
41 await expect(fetch(url)).rejects.toThrow();75 await expect(fetch(url)).rejects.toThrow();
42 });76 });
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 });
68});77});
apps/web/src/components/chat/ExpandedImagePreview.tsx+110-11
...@@ -1,10 +1,34 @@...@@ -1,10 +1,34 @@
1import type { ComposerFileAttachment } from "../../composerDraftStore";1import type { ComposerFileAttachment } from "../../composerDraftStore";
2import { type ChatImageAttachment, isVideoAttachment } from "../../types";2import { 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
4export interface ExpandedImageItem {24export interface ExpandedImageItem {
5 src: string;25 src: string;
6 name: string;26 name: string;
7 type?: "video";27 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;
8}32}
933
10export interface ExpandedImagePreview {34export interface ExpandedImagePreview {
...@@ -12,23 +36,98 @@ export interface ExpandedImagePreview {...@@ -12,23 +36,98 @@ export interface ExpandedImagePreview {
12 index: number;36 index: number;
13}37}
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
15export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {125export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {
16 const url = URL.createObjectURL(file);126 const url = URL.createObjectURL(file);
17 video.src = url;127 video.src = url;
18 return () => URL.revokeObjectURL(url);128 return () => URL.revokeObjectURL(url);
19}129}
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
32export function buildExpandedImagePreview(131export function buildExpandedImagePreview(
33 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,132 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,
34 selectedImageId: string,133 selectedImageId: string,
apps/web/src/components/chat/MessagesTimeline.tsx+1
...@@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2556 resource={viewedImage.resource}2556 resource={viewedImage.resource}
2557 alt={viewedImage.alt}2557 alt={viewedImage.alt}
2558 srcFragment={viewedImage.srcFragment}2558 srcFragment={viewedImage.srcFragment}
2559 workspaceRoot={workspaceRoot}
2559 style={{ maxHeight: "16rem" }}2560 style={{ maxHeight: "16rem" }}
2560 onImageExpand={onImageExpand}2561 onImageExpand={onImageExpand}
2561 />2562 />
apps/web/src/components/chat/externalLinkContextMenu.test.ts+2
...@@ -181,6 +181,8 @@ describe("external chat link context menu", () => {...@@ -181,6 +181,8 @@ describe("external chat link context menu", () => {
181 it.each([181 it.each([
182 ["https://example.com", "example.com"],182 ["https://example.com", "example.com"],
183 ["http://localhost:3000/path", "localhost"],183 ["http://localhost:3000/path", "localhost"],
184 ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"],
185 ["//", null],
184 ["#details", null],186 ["#details", null],
185 ["mailto:hello@example.com", null],187 ["mailto:hello@example.com", null],
186 ["file:///tmp/example.txt", null],188 ["file:///tmp/example.txt", null],
apps/web/src/components/chat/externalLinkContextMenu.ts+1-1
...@@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions {...@@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions {
76export function resolveExternalWebLinkHost(href: string | undefined): string | null {76export function resolveExternalWebLinkHost(href: string | undefined): string | null {
77 if (!href) return null;77 if (!href) return null;
78 try {78 try {
79 const url = new URL(href);79 const url = new URL(href.startsWith("//") ? `https:${href}` : href);
80 if (url.protocol !== "http:" && url.protocol !== "https:") return null;80 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
81 return url.hostname || null;81 return url.hostname || null;
82 } catch {82 } catch {
apps/web/src/components/files/FilePreviewPanel.tsx+115-23
...@@ -4,7 +4,10 @@ import type {...@@ -4,7 +4,10 @@ import type {
4 ResolvedKeybindingsConfig,4 ResolvedKeybindingsConfig,
5 ScopedThreadRef,5 ScopedThreadRef,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";7import {
8 isWorkspaceImagePreviewPath,
9 isWorkspaceVideoPreviewPath,
10} from "@t3tools/shared/filePreview";
8import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";11import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
9import { Editor } from "@pierre/diffs/editor";12import { Editor } from "@pierre/diffs/editor";
10import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";13import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
...@@ -12,13 +15,16 @@ import {...@@ -12,13 +15,16 @@ import {
12 isAtomCommandInterrupted,15 isAtomCommandInterrupted,
13 squashAtomCommandFailure,16 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";17} from "@t3tools/client-runtime/state/runtime";
18import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
15import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";19import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
16import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";21import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1822
19import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";23import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20import { useAssetUrlState } from "~/assets/assetUrls";24import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
21import { OpenInPicker } from "~/components/chat/OpenInPicker";25import { OpenInPicker } from "~/components/chat/OpenInPicker";
26import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
27import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
22import { useRemoteOpenState } from "~/remoteOpen";28import { useRemoteOpenState } from "~/remoteOpen";
23import { useClientSettings } from "~/hooks/useSettings";29import { useClientSettings } from "~/hooks/useSettings";
24import { useTheme } from "~/hooks/useTheme";30import { useTheme } from "~/hooks/useTheme";
...@@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: {...@@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: {
135 readonly environmentId: EnvironmentId;141 readonly environmentId: EnvironmentId;
136 readonly threadRef: ScopedThreadRef;142 readonly threadRef: ScopedThreadRef;
137 readonly absolutePath: string;143 readonly absolutePath: string;
144 readonly workspaceRoot: string;
138 readonly alt: string;145 readonly alt: string;
139 readonly workspaceMutationId: string | null;146 readonly workspaceMutationId: string | null;
140}) {147}) {
141 const assetUrl = useAssetUrlState(props.environmentId, {148 const resource = useMemo(
142 _tag: "workspace-file",149 () => ({
143 threadId: props.threadRef.threadId,150 _tag: "workspace-file" as const,
144 path: props.absolutePath,151 threadId: props.threadRef.threadId,
145 });152 path: props.absolutePath,
153 }),
154 [props.threadRef.threadId, props.absolutePath],
155 );
156 const assetUrl = useAssetUrlState(props.environmentId, resource);
146 const [failedUrl, setFailedUrl] = useState<string | null>(null);157 const [failedUrl, setFailedUrl] = useState<string | null>(null);
147 const revisionSuffix =158 const revisionSuffix =
148 props.workspaceMutationId === null159 props.workspaceMutationId === null
149 ? ""160 ? ""
150 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;161 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
151 const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;162 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
153 if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {171 if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {
154 return (172 return (
155 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">173 <MediaActions source={actionsSource}>
156 Unable to load workspace image.174 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
157 </div>175 Unable to load workspace image.
176 </div>
177 </MediaActions>
158 );178 );
159 }179 }
160180
161 return assetUrl._tag === "Success" && imageUrl !== null ? (181 return assetUrl._tag === "Success" && imageUrl !== null ? (
162 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">182 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
163 <img183 <MediaActions source={actionsSource}>
164 className="max-h-full max-w-full object-contain"184 <img
165 src={imageUrl}185 className="max-h-full max-w-full object-contain"
166 alt={props.alt}186 src={imageUrl}
167 onError={() => setFailedUrl(imageUrl)}187 alt={props.alt}
168 />188 onError={() => setFailedUrl(imageUrl)}
189 />
190 </MediaActions>
169 </div>191 </div>
170 ) : (192 ) : (
171 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">193 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
...@@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: {...@@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: {
174 );196 );
175}197}
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
177function clampFileLine(contents: string, requestedLine: number): number {253function clampFileLine(contents: string, requestedLine: number): number {
178 let lineCount = 1;254 let lineCount = 1;
179 for (let index = 0; index < contents.length; index += 1) {255 for (let index = 0; index < contents.length; index += 1) {
...@@ -791,8 +867,10 @@ export default function FilePreviewPanel({...@@ -791,8 +867,10 @@ export default function FilePreviewPanel({
791 const openPreview = useAtomCommand(previewEnvironment.open, {867 const openPreview = useAtomCommand(previewEnvironment.open, {
792 reportFailure: false,868 reportFailure: false,
793 });869 });
794 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);870 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
795 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);871 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
872 const isMedia = isImage || isVideo;
873 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
796 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);874 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
797 // Reading markdown rendered is a preference, not a property of one file. Keeping875 // Reading markdown rendered is a preference, not a property of one file. Keeping
798 // it on the panel meant a thread switch dropped it and forced source back.876 // it on the panel meant a thread switch dropped it and forced source back.
...@@ -816,7 +894,10 @@ export default function FilePreviewPanel({...@@ -816,7 +894,10 @@ export default function FilePreviewPanel({
816 (revealLine === null ||894 (revealLine === null ||
817 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId));895 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId));
818 const canOpenInBrowser =896 const canOpenInBrowser =
819 relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);897 relativePath !== null &&
898 !isVideo &&
899 isPreviewSupportedInRuntime() &&
900 isBrowserPreviewFile(relativePath);
820 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;901 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
821 const breadcrumbs = useMemo(902 const breadcrumbs = useMemo(
822 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),903 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
...@@ -824,7 +905,7 @@ export default function FilePreviewPanel({...@@ -824,7 +905,7 @@ export default function FilePreviewPanel({
824 );905 );
825 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);906 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
826 useWorkspaceMutationRefresh({907 useWorkspaceMutationRefresh({
827 enabled: relativePath !== null && !isImage && !selectedFilePending,908 enabled: relativePath !== null && !isMedia && !selectedFilePending,
828 mutationId: workspaceMutationId,909 mutationId: workspaceMutationId,
829 refresh: file.refresh,910 refresh: file.refresh,
830 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,911 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
...@@ -999,7 +1080,7 @@ export default function FilePreviewPanel({...@@ -999,7 +1080,7 @@ export default function FilePreviewPanel({
999 </Tooltip>1080 </Tooltip>
1000 </div>1081 </div>
1001 ) : null}1082 ) : null}
1002 {relativePath && file.data?.truncated ? (1083 {relativePath && !isMedia && file.data?.truncated ? (
1003 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">1084 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">
1004 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.1085 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
1005 </div>1086 </div>
...@@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({...@@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({
1011 relativePath ? "flex" : "hidden",1092 relativePath ? "flex" : "hidden",
1012 )}1093 )}
1013 >1094 >
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 ? (
1015 <WorkspaceImagePreview1106 <WorkspaceImagePreview
1016 key={absolutePath}1107 key={absolutePath}
1017 environmentId={environmentId}1108 environmentId={environmentId}
1018 threadRef={threadRef}1109 threadRef={threadRef}
1019 absolutePath={absolutePath}1110 absolutePath={absolutePath}
1111 workspaceRoot={cwd}
1020 alt={relativePath}1112 alt={relativePath}
1021 workspaceMutationId={workspaceMutationId}1113 workspaceMutationId={workspaceMutationId}
1022 />1114 />
...@@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({...@@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({
1099 selectedPathRevealId={revealRequestId}1191 selectedPathRevealId={revealRequestId}
1100 onOpenFile={onOpenFile}1192 onOpenFile={onOpenFile}
1101 workspaceMutationId={workspaceMutationId}1193 workspaceMutationId={workspaceMutationId}
1102 {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}1194 {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})}
1103 />1195 />
1104 </aside>1196 </aside>
1105 ) : null}1197 ) : null}
apps/web/src/components/files/projectFilesQueryState.ts+11-3
...@@ -4,6 +4,10 @@ import type {...@@ -4,6 +4,10 @@ import type {
4 ProjectListEntriesResult,4 ProjectListEntriesResult,
5 ProjectReadFileResult,5 ProjectReadFileResult,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {
8 isWorkspaceImagePreviewPath,
9 isWorkspaceVideoPreviewPath,
10} from "@t3tools/shared/filePreview";
7import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
8import * as Option from "effect/Option";12import * as Option from "effect/Option";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";13import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -178,9 +182,13 @@ export function useProjectFileQuery(...@@ -178,9 +182,13 @@ export function useProjectFileQuery(
178 relativePath: string | null,182 relativePath: string | null,
179 enabled = true,183 enabled = true,
180): ProjectQueryState<ProjectReadFileResult> {184): ProjectQueryState<ProjectReadFileResult> {
181 const atom = enabled185 const isMedia =
182 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)186 relativePath !== null &&
183 : EMPTY_PROJECT_FILE_QUERY_ATOM;187 (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath));
188 const atom =
189 enabled && !isMedia
190 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
191 : EMPTY_PROJECT_FILE_QUERY_ATOM;
184 const result = useAtomValue(atom);192 const result = useAtomValue(atom);
185 const refreshAtom = useAtomRefresh(atom);193 const refreshAtom = useAtomRefresh(atom);
186 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);194 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);...@@ -14,10 +14,8 @@ export const PullRequestMarkdownContext = createContext<string | null>(null);
14 * A pull request body, rendered with the app's markdown renderer plus a card for each upload14 * A pull request body, rendered with the app's markdown renderer plus a card for each upload
15 * embedded in it, which that renderer drops on the floor.15 * embedded in it, which that renderer drops on the floor.
16 *16 *
17 * The card links out instead of playing in place. GitHub serves the file as uploaded — Mac17 * These upload URLs do not identify the media format. The card links to GitHub, where the
18 * recordings are `video/quicktime`, which no Chromium decodes — and the desktop window's18 * original upload can be opened or downloaded even when its codec cannot play in the client.
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.
21 */19 */
22export function PullRequestMarkdown({20export function PullRequestMarkdown({
23 text,21 text,
apps/web/src/contextMenuFallback.ts+4
...@@ -196,6 +196,10 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H...@@ -196,6 +196,10 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H
196// dismiss it with the same result as an outside click or Escape.196// dismiss it with the same result as an outside click or Escape.
197let activeContextMenuDismiss: (() => void) | null = null;197let activeContextMenuDismiss: (() => void) | null = null;
198198
199export function isContextMenuOpen(): boolean {
200 return activeContextMenuDismiss !== null;
201}
202
199/**203/**
200 * Closes the currently open fallback context menu, resolving its show() with204 * Closes the currently open fallback context menu, resolving its show() with
201 * null (the same result as dismissing by outside click or Escape). No-op when205 * 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", () => {...@@ -163,6 +163,7 @@ describe("resolveMarkdownFileLinkTarget", () => {
163163
164 it("ignores external urls", () => {164 it("ignores external urls", () => {
165 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();165 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
166 expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull();
166 });167 });
167168
168 it("does not double-decode file URLs", () => {169 it("does not double-decode file URLs", () => {
apps/web/src/markdown-links.ts+9-151
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import {
2 inlineCodeFilePathCandidate,
3 isConventionalFilePosition,
4} from "@t3tools/client-runtime/markdown-links";
5
1import { formatWorkspaceRelativePath } from "./filePathDisplay";6import { formatWorkspaceRelativePath } from "./filePathDisplay";
2import {7import {
3 isTerminalLinkActivation,8 isTerminalLinkActivation,
...@@ -190,7 +195,7 @@ export function resolveMarkdownFileLinkTarget(...@@ -190,7 +195,7 @@ export function resolveMarkdownFileLinkTarget(
190): string | null {195): string | null {
191 if (!href) return null;196 if (!href) return null;
192 const rawHref = normalizeMarkdownLinkDestination(href);197 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
195 const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")200 const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")
196 ? parseFileUrlHref(rawHref)201 ? parseFileUrlHref(rawHref)
...@@ -221,124 +226,6 @@ export function resolveMarkdownFileLinkTarget(...@@ -221,124 +226,6 @@ export function resolveMarkdownFileLinkTarget(
221 return resolvePathLinkTarget(pathWithPosition, cwd);226 return resolvePathLinkTarget(pathWithPosition, cwd);
222}227}
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
342/**229/**
343 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,230 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
344 * `origin/main`) rather than deliberate link destinations, so auto-linking231 * `origin/main`) rather than deliberate link destinations, so auto-linking
...@@ -349,33 +236,8 @@ export function resolveInlineCodeFileLinkMeta(...@@ -349,33 +236,8 @@ export function resolveInlineCodeFileLinkMeta(
349 codeText: string,236 codeText: string,
350 cwd?: string,237 cwd?: string,
351): MarkdownFileLinkMeta | null {238): MarkdownFileLinkMeta | null {
352 const trimmed = codeText.trim();239 const candidate = inlineCodeFilePathCandidate(codeText);
353 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;240 if (candidate === null) 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 }
379241
380 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);242 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
381 if (resolved) return resolved;243 if (resolved) return resolved;
...@@ -383,11 +245,7 @@ export function resolveInlineCodeFileLinkMeta(...@@ -383,11 +245,7 @@ export function resolveInlineCodeFileLinkMeta(
383 // `Makefile:12` — conventional extensionless names fail the generic245 // `Makefile:12` — conventional extensionless names fail the generic
384 // markdown-link candidate patterns, but here the :line suffix already246 // markdown-link candidate patterns, but here the :line suffix already
385 // marked the span as a file reference.247 // marked the span as a file reference.
386 if (248 if (cwd && isConventionalFilePosition(candidate)) {
387 cwd &&
388 BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) &&
389 EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, ""))
390 ) {
391 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);249 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
392 }250 }
393 return null;251 return null;
apps/web/src/pierre-icons.ts+11
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 getBuiltInSpriteSheet,3 getBuiltInSpriteSheet,
4 type FileTreeIcons,4 type FileTreeIcons,
5} from "@pierre/trees";5} from "@pierre/trees";
6import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video";
67
7export interface PierreIconResolution {8export interface PierreIconResolution {
8 name: string;9 name: string;
...@@ -13,6 +14,13 @@ const PIERRE_ICON_SPRITE_ID = "t3code-pierre-file-icon-sprite";...@@ -13,6 +14,13 @@ const PIERRE_ICON_SPRITE_ID = "t3code-pierre-file-icon-sprite";
1314
14const T3_FILE_ICON_SPRITE = `15const T3_FILE_ICON_SPRITE = `
15<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" aria-hidden="true">16<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>
16 <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32">24 <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32">
17 <path d="M2 2H30V30H2" fill="#c12127" />25 <path d="M2 2H30V30H2" fill="#c12127" />
18 <path d="M7.25 7.25h17.5v17.5h-3.5v-14H16v14H7.25" fill="#fff" />26 <path d="M7.25 7.25h17.5v17.5h-3.5v-14H16v14H7.25" fill="#fff" />
...@@ -50,6 +58,9 @@ export const T3_PIERRE_ICONS = {...@@ -50,6 +58,9 @@ export const T3_PIERRE_ICONS = {
50 "pnpm-lock.yaml": "t3-file-icon-pnpm",58 "pnpm-lock.yaml": "t3-file-icon-pnpm",
51 "pnpm-workspace.yaml": "t3-file-icon-pnpm",59 "pnpm-workspace.yaml": "t3-file-icon-pnpm",
52 },60 },
61 byFileExtension: Object.fromEntries(
62 VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]),
63 ),
53} satisfies FileTreeIcons;64} satisfies FileTreeIcons;
5465
55const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS);66const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS);
apps/web/src/state/use-atom-query-runner.ts+5-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { RegistryContext } from "@effect/atom-react";1import { RegistryContext } from "@effect/atom-react";
2import {2import {
3 executeAtomQuery,3 executeAtomQuery,
4 type AtomCommandOptions,4 type AtomQueryOptions,
5 type AtomCommandResult,5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { AsyncResult, type Atom } from "effect/unstable/reactivity";7import { AsyncResult, type Atom } from "effect/unstable/reactivity";
...@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";...@@ -9,12 +9,13 @@ import { useCallback, useContext } from "react";
99
10export function useAtomQueryRunner<T, A, E>(10export function useAtomQueryRunner<T, A, E>(
11 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,11 family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,
12 options?: string | AtomCommandOptions,12 options?: string | AtomQueryOptions,
13): (target: T) => Promise<AtomCommandResult<A, E>> {13): (target: T) => Promise<AtomCommandResult<A, E>> {
14 const registry = useContext(RegistryContext);14 const registry = useContext(RegistryContext);
15 const explicitLabel = typeof options === "string" ? options : options?.label;15 const explicitLabel = typeof options === "string" ? options : options?.label;
16 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);16 const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true);
17 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);17 const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true);
18 const refresh = typeof options === "string" ? false : (options?.refresh ?? false);
1819
19 return useCallback(20 return useCallback(
20 (target: T) => {21 (target: T) => {
...@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(...@@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>(
23 label: explicitLabel ?? atom.label?.[0] ?? "atom query",24 label: explicitLabel ?? atom.label?.[0] ?? "atom query",
24 reportFailure,25 reportFailure,
25 reportDefect,26 reportDefect,
27 refresh,
26 });28 });
27 },29 },
28 [explicitLabel, family, registry, reportDefect, reportFailure],30 [explicitLabel, family, registry, refresh, reportDefect, reportFailure],
29 );31 );
30}32}
docs/internals/environment-auth.md+34
...@@ -28,6 +28,40 @@ managed relay connectivity:...@@ -28,6 +28,40 @@ managed relay connectivity:
28The desktop bootstrap credential and command-line administrative bootstrap28The desktop bootstrap credential and command-line administrative bootstrap
29credentials additionally grant `access:read access:write relay:write`.29credentials 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
31## Authentication Flows65## Authentication Flows
3266
33### Browser Session67### Browser Session
docs/user/composer.md+43-1
...@@ -34,7 +34,7 @@ viewer and PDFs open the system chooser....@@ -34,7 +34,7 @@ viewer and PDFs open the system chooser.
34Select a video attachment before or after sending to play it. Web and desktop use the browser's34Select a video attachment before or after sending to play it. Web and desktop use the browser's
35built-in controls. On mobile, videos open in a full-screen player with native playback controls.35built-in controls. On mobile, videos open in a full-screen player with native playback controls.
36Supported videos show a thumbnail in the conversation and composer.36Supported videos show a thumbnail in the conversation and composer.
37On iOS, received videos stream from their environment as they play. Supported formats and codecs37On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs
38depend on the browser or device; you can save an unsupported video to open it in another app.38depend on the browser or device; you can save an unsupported video to open it in another app.
3939
40On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the40On 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...@@ -52,6 +52,48 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
53when starting a thread or changing an existing thread's model.53when 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
55## Changing projects97## Changing projects
5698
57On web and desktop, changing the project from a new thread keeps the current environment when that99On 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 @@...@@ -19,6 +19,14 @@
19 "types": "./src/markdownImages.ts",19 "types": "./src/markdownImages.ts",
20 "default": "./src/markdownImages.ts"20 "default": "./src/markdownImages.ts"
21 },21 },
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 },
22 "./codex-file-citations": {30 "./codex-file-citations": {
23 "types": "./src/codexFileCitations.ts",31 "types": "./src/codexFileCitations.ts",
24 "default": "./src/codexFileCitations.ts"32 "default": "./src/codexFileCitations.ts"
packages/client-runtime/src/markdownImages.ts+1-1
...@@ -67,7 +67,7 @@ function joinWorkspacePath(workspaceRoot: string, relativePath: string): string...@@ -67,7 +67,7 @@ function joinWorkspacePath(workspaceRoot: string, relativePath: string): string
67}67}
6868
69/**69/**
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.
71 * Filesystem paths belong to the environment host and must never reach a71 * Filesystem paths belong to the environment host and must never reach a
72 * browser or native image component without first becoming a signed asset URL.72 * browser or native image component without first becoming a signed asset URL.
73 */73 */
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", () => {...@@ -58,7 +58,7 @@ describe("resolveViewedImageAsset", () => {
58 }),58 }),
59 ).toEqual({59 ).toEqual({
60 resource: {60 resource: {
61 _tag: "workspace-file",61 _tag: "media-file",
62 threadId,62 threadId,
63 path: "/workspace/screens/logo.svg",63 path: "/workspace/screens/logo.svg",
64 },64 },
packages/client-runtime/src/work-log/presentation.ts+2-2
...@@ -99,7 +99,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin...@@ -99,7 +99,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin
99}99}
100100
101export interface ViewedImageAsset {101export interface ViewedImageAsset {
102 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;102 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
103 readonly alt: string;103 readonly alt: string;
104 readonly srcFragment: string;104 readonly srcFragment: string;
105}105}
...@@ -129,7 +129,7 @@ export function resolveViewedImageAsset(...@@ -129,7 +129,7 @@ export function resolveViewedImageAsset(
129 return {129 return {
130 resource: attachmentId130 resource: attachmentId
131 ? { _tag: "attachment", attachmentId }131 ? { _tag: "attachment", attachmentId }
132 : { _tag: "workspace-file", threadId: input.threadId, path },132 : { _tag: "media-file", threadId: input.threadId, path },
133 alt: path.split(/[\\/]/).at(-1) ?? "image",133 alt: path.split(/[\\/]/).at(-1) ?? "image",
134 srcFragment: markdownImageSourceFragment(source),134 srcFragment: markdownImageSourceFragment(source),
135 };135 };
packages/contracts/src/assets.ts+13-3
...@@ -15,6 +15,10 @@ export const AssetResource = Schema.Union([...@@ -15,6 +15,10 @@ export const AssetResource = Schema.Union([
15 threadId: ThreadId,15 threadId: ThreadId,
16 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),16 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
17 }),17 }),
18 Schema.TaggedStruct("media-file", {
19 threadId: ThreadId,
20 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
21 }),
18 Schema.TaggedStruct("attachment", {22 Schema.TaggedStruct("attachment", {
19 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),23 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
20 /** Display name and mime from the `ChatAttachment` the caller holds. The24 /** Display name and mime from the `ChatAttachment` the caller holds. The
...@@ -152,7 +156,9 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass...@@ -152,7 +156,9 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass
152 },156 },
153) {157) {
154 override get message(): string {158 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.";
156 }162 }
157}163}
158164
...@@ -164,7 +170,9 @@ export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass<...@@ -164,7 +170,9 @@ export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass<
164 },170 },
165) {171) {
166 override get message(): string {172 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.";
168 }176 }
169}177}
170178
...@@ -175,7 +183,9 @@ export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<As...@@ -175,7 +183,9 @@ export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<As
175 },183 },
176) {184) {
177 override get message(): string {185 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.";
179 }189 }
180}190}
181191
packages/shared/src/filePreview.test.ts+27
...@@ -4,6 +4,8 @@ import {...@@ -4,6 +4,8 @@ import {
4 isWorkspaceBrowserPreviewPath,4 isWorkspaceBrowserPreviewPath,
5 isWorkspaceImagePreviewPath,5 isWorkspaceImagePreviewPath,
6 isWorkspacePreviewEntryPath,6 isWorkspacePreviewEntryPath,
7 isWorkspaceVideoPreviewPath,
8 mediaKindFromPath,
7} from "./filePreview.ts";9} from "./filePreview.ts";
810
9describe("workspace file previews", () => {11describe("workspace file previews", () => {
...@@ -34,3 +36,28 @@ describe("workspace file previews", () => {...@@ -34,3 +36,28 @@ describe("workspace file previews", () => {
34 },36 },
35 );37 );
36});38});
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 @@...@@ -1,3 +1,5 @@
1import { videoMimeType } from "./video.ts";
2
1export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;3export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
24
3export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [5export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
...@@ -11,6 +13,57 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [...@@ -11,6 +13,57 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
11 ".webp",13 ".webp",
12] as const;14] 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
14function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {67function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
15 const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";68 const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
16 return extensions.some((extension) => pathWithoutQuery.endsWith(extension));69 return extensions.some((extension) => pathWithoutQuery.endsWith(extension));
...@@ -24,6 +77,11 @@ export function isWorkspaceImagePreviewPath(path: string): boolean {...@@ -24,6 +77,11 @@ export function isWorkspaceImagePreviewPath(path: string): boolean {
24 return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);77 return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);
25}78}
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
27export function isWorkspacePreviewEntryPath(path: string): boolean {85export function isWorkspacePreviewEntryPath(path: string): boolean {
28 return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);86 return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
29}87}
packages/shared/src/video.ts+2
...@@ -8,6 +8,8 @@ const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([...@@ -8,6 +8,8 @@ const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([
8 ["webm", "video/webm"],8 ["webm", "video/webm"],
9]);9]);
1010
11export const VIDEO_FILE_EXTENSIONS = Object.freeze([...VIDEO_MIME_TYPE_BY_EXTENSION.keys()]);
12
11/** Recognizes videos even when the file picker omitted their MIME type. */13/** Recognizes videos even when the file picker omitted their MIME type. */
12export function videoMimeType(attachment: {14export function videoMimeType(attachment: {
13 readonly name: string;15 readonly name: string;
pnpm-lock.yaml+6-2
...@@ -273,7 +273,7 @@ importers:...@@ -273,7 +273,7 @@ importers:
273 version: link:../../packages/contracts273 version: link:../../packages/contracts
274 '@t3tools/mobile-markdown-text':274 '@t3tools/mobile-markdown-text':
275 specifier: file:./modules/t3-markdown-text275 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)
277 '@t3tools/mobile-review-diff-native':277 '@t3tools/mobile-review-diff-native':
278 specifier: file:./modules/t3-review-diff278 specifier: file:./modules/t3-review-diff
279 version: file:apps/mobile/modules/t3-review-diff279 version: file:apps/mobile/modules/t3-review-diff
...@@ -4433,6 +4433,8 @@ packages:...@@ -4433,6 +4433,8 @@ packages:
4433 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text':4433 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text':
4434 resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory}4434 resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory}
4435 peerDependencies:4435 peerDependencies:
4436 '@t3tools/client-runtime': '*'
4437 '@t3tools/shared': '*'
4436 expo-asset: '*'4438 expo-asset: '*'
4437 expo-clipboard: '*'4439 expo-clipboard: '*'
4438 expo-haptics: '*'4440 expo-haptics: '*'
...@@ -14253,8 +14255,10 @@ snapshots:...@@ -14253,8 +14255,10 @@ snapshots:
14253 dependencies:14255 dependencies:
14254 defer-to-connect: 2.0.114256 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)':
14257 dependencies:14259 dependencies:
14260 '@t3tools/client-runtime': link:packages/client-runtime
14261 '@t3tools/shared': link:packages/shared
14258 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)14262 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)
14259 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)14263 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)
14260 expo-haptics: 57.0.2(expo@57.0.18)14264 expo-haptics: 57.0.2(expo@57.0.18)