| author | |
| committer | |
| log | beae2147a9487ec47ac992319f2216914b4cb62d |
| tree | e134b57f80ff35161c357f8b7c2b163c47738839 |
| parent | 60cef47ec983637ddc68faed7b1488b6f3c3a175 |
| signature | Signed by PGP key B5690EEEBB952194 |
82 files changed, 4144 insertions(+), 682 deletions(-)
apps/desktop/src/electron/ElectronProtocol.test.ts+1-1| ... | ... | @@ -225,7 +225,7 @@ describe("ElectronProtocol", () => { |
| 225 | 225 | "http:", |
| 226 | 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 | 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 | 87 | `script-src ${scriptSources.join(" ")}`, |
| 88 | 88 | `connect-src ${connectSources.join(" ")}`, |
| 89 | 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 | 91 | "style-src 'self' 'unsafe-inline'", |
| 92 | 92 | `font-src 'self' ${input.scheme}: data:`, |
| 93 | 93 | "worker-src 'self' blob:", |
| ... | ... | @@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void { |
| 118 | 118 | secure: true, |
| 119 | 119 | supportFetchAPI: true, |
| 120 | 120 | corsEnabled: true, |
| 121 | stream: true, | |
| 121 | 122 | }, |
| 122 | 123 | }, |
| 123 | 124 | { |
| ... | ... | @@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void { |
| 127 | 128 | secure: true, |
| 128 | 129 | supportFetchAPI: true, |
| 129 | 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 | 26 | "./types": "./src/SelectableMarkdownText.types.ts" |
| 27 | 27 | }, |
| 28 | 28 | "peerDependencies": { |
| 29 | "@t3tools/client-runtime": "*", | |
| 30 | "@t3tools/shared": "*", | |
| 29 | 31 | "expo-asset": "*", |
| 30 | 32 | "expo-clipboard": "*", |
| 31 | 33 | "expo-haptics": "*", |
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs+2| ... | ... | @@ -66,6 +66,7 @@ const colors = { |
| 66 | 66 | terraform: "#693acf", |
| 67 | 67 | text: "#84848a", |
| 68 | 68 | typescript: "#1a85d4", |
| 69 | video: "#a631be", | |
| 69 | 70 | vite: "#a631be", |
| 70 | 71 | vscode: "#1a85d4", |
| 71 | 72 | vue: "#199f43", |
| ... | ... | @@ -83,6 +84,7 @@ const customIcons = { |
| 83 | 84 | pnpm: "t3-file-icon-pnpm", |
| 84 | 85 | readme: "t3-file-icon-readme", |
| 85 | 86 | tsconfig: "t3-file-icon-tsconfig", |
| 87 | video: "t3-file-icon-video", | |
| 86 | 88 | }; |
| 87 | 89 | |
| 88 | 90 | function 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 | 51 | text: require("../assets/file-icons/pierre_text.png"), |
| 52 | 52 | tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), |
| 53 | 53 | typescript: require("../assets/file-icons/pierre_typescript.png"), |
| 54 | video: require("../assets/file-icons/pierre_video.png"), | |
| 54 | 55 | vite: require("../assets/file-icons/pierre_vite.png"), |
| 55 | 56 | vscode: require("../assets/file-icons/pierre_vscode.png"), |
| 56 | 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 | import { | |
| 2 | inlineCodeFilePathCandidate, | |
| 3 | isConventionalFilePosition, | |
| 4 | } from "@t3tools/client-runtime/markdown-links"; | |
| 5 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 6 | ||
| 1 | 7 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; |
| 2 | 8 | |
| 3 | 9 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; |
| ... | ... | @@ -253,15 +259,22 @@ function normalizeDestination(value: string): string { |
| 253 | 259 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; |
| 254 | 260 | } |
| 255 | 261 | |
| 262 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ | |
| 263 | export function normalizeNativeMarkdownUrl(value: string): string { | |
| 264 | return value.startsWith("//") ? `https:${value}` : value; | |
| 265 | } | |
| 266 | ||
| 256 | 267 | function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { |
| 257 | 268 | try { |
| 258 | 269 | const parsed = new URL(href); |
| 259 | 270 | if (parsed.protocol.toLowerCase() !== "file:") { |
| 260 | 271 | return null; |
| 261 | 272 | } |
| 262 | const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) | |
| 263 | ? parsed.pathname.slice(1) | |
| 273 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; | |
| 274 | const rawPath = uncHostname | |
| 275 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` | |
| 264 | 276 | : parsed.pathname; |
| 277 | const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; | |
| 265 | 278 | return { path, hash: parsed.hash }; |
| 266 | 279 | } catch { |
| 267 | 280 | return null; |
| ... | ... | @@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean { |
| 327 | 340 | if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { |
| 328 | 341 | return true; |
| 329 | 342 | } |
| 343 | if (isConventionalFilePosition(value)) return true; | |
| 330 | 344 | return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); |
| 331 | 345 | } |
| 332 | 346 | |
| ... | ... | @@ -338,6 +352,7 @@ function fileLabel(value: string): string { |
| 338 | 352 | |
| 339 | 353 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 340 | 354 | const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); |
| 355 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; | |
| 341 | 356 | const exactIcon = FILE_ICON_BY_NAME[basename]; |
| 342 | 357 | if (exactIcon) return exactIcon; |
| 343 | 358 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { |
| ... | ... | @@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 354 | 369 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { |
| 355 | 370 | const normalized = normalizeDestination(href); |
| 356 | 371 | try { |
| 357 | const parsed = new URL(normalized); | |
| 372 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); | |
| 358 | 373 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { |
| 359 | 374 | return { |
| 360 | 375 | kind: "external", |
| ... | ... | @@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 399 | 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. */ | |
| 419 | export 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 | 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | 2 | |
| 3 | 3 | import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; |
| 4 | import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; | |
| 4 | import { | |
| 5 | resolveMarkdownInlineCodePresentation, | |
| 6 | resolveMarkdownLinkPresentation, | |
| 7 | type MarkdownFileIcon, | |
| 8 | } from "./markdownLinks"; | |
| 5 | 9 | |
| 6 | 10 | export interface NativeMarkdownTextRun { |
| 7 | 11 | readonly text: string; |
| ... | ... | @@ -283,8 +287,17 @@ function appendNode( |
| 283 | 287 | return appendRun(runs, textNodeContent(nodeTextContent(node)), context); |
| 284 | 288 | case "html_inline": |
| 285 | 289 | return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); |
| 286 | case "code_inline": | |
| 287 | return appendRun(runs, nodeTextContent(node), { ...context, code: true }); | |
| 290 | case "code_inline": { | |
| 291 | const content = nodeTextContent(node); | |
| 292 | const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); | |
| 293 | return presentation | |
| 294 | ? appendRun(runs, presentation.label, { | |
| 295 | ...context, | |
| 296 | href: presentation.href, | |
| 297 | fileIcon: presentation.icon, | |
| 298 | }) | |
| 299 | : appendRun(runs, content, { ...context, code: true }); | |
| 300 | } | |
| 288 | 301 | case "soft_break": |
| 289 | 302 | return appendRun(runs, " ", context); |
| 290 | 303 | case "line_break": |
apps/mobile/src/components/FilePreview.ios.tsx+13-1| ... | ... | @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react"; |
| 3 | 3 | import { Alert } from "react-native"; |
| 4 | 4 | |
| 5 | 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; |
| 6 | import { MediaImagePreview } from "./MediaImagePreview"; | |
| 6 | 7 | |
| 7 | 8 | const NativeControls = requireNativeModule<{ |
| 8 | 9 | presentFile( |
| ... | ... | @@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{ |
| 14 | 15 | dismissFile(identifier: string): Promise<void>; |
| 15 | 16 | }>("T3NativeControls"); |
| 16 | 17 | |
| 17 | export function FilePreview(props: { | |
| 18 | function NativeFilePreview(props: { | |
| 18 | 19 | readonly source: ResolvedFilePreviewSource; |
| 19 | 20 | readonly onRequestClose: () => void; |
| 20 | 21 | }) { |
| ... | ... | @@ -41,3 +42,14 @@ export function FilePreview(props: { |
| 41 | 42 | |
| 42 | 43 | return null; |
| 43 | 44 | } |
| 45 | ||
| 46 | export 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 | 4 | |
| 5 | 5 | import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; |
| 6 | 6 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; |
| 7 | import { MediaImagePreview } from "./MediaImagePreview"; | |
| 7 | 8 | |
| 8 | 9 | function PdfPreview(props: { |
| 9 | 10 | readonly source: ResolvedFilePreviewSource; |
| ... | ... | @@ -39,6 +40,7 @@ export function FilePreview(props: { |
| 39 | 40 | readonly onRequestClose: () => void; |
| 40 | 41 | }) { |
| 41 | 42 | if (props.source.kind === "pdf") return <PdfPreview {...props} />; |
| 43 | if (props.source.actionsSource) return <MediaImagePreview {...props} />; | |
| 42 | 44 | return ( |
| 43 | 45 | <ImageViewing |
| 44 | 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 | 5 | |
| 6 | 6 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 7 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import type { MediaActionsSource } from "../lib/mediaActions"; | |
| 8 | 9 | import { useAssetUrlState } from "../state/assets"; |
| 9 | 10 | import { usePreparedConnection } from "../state/session"; |
| 10 | 11 | import { FilePreview } from "./FilePreview"; |
| ... | ... | @@ -14,6 +15,8 @@ export interface ResolvedFilePreviewSource { |
| 14 | 15 | readonly uri: string; |
| 15 | 16 | readonly name?: string; |
| 16 | 17 | readonly sourceIdentifier?: string; |
| 18 | readonly srcFragment?: string; | |
| 19 | readonly actionsSource?: MediaActionsSource; | |
| 17 | 20 | } |
| 18 | 21 | |
| 19 | 22 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & |
| ... | ... | @@ -40,13 +43,18 @@ function ResolvedFilePreview(props: { |
| 40 | 43 | (connection._tag === "None" || asset._tag === "Failure"); |
| 41 | 44 | useEffect(() => Keyboard.dismiss(), []); |
| 42 | 45 | useEffect(() => { |
| 43 | if (uri === null && asset._tag === "Success") setUri(asset.url); | |
| 44 | }, [uri, asset]); | |
| 46 | if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); | |
| 47 | }, [uri, asset, source.srcFragment]); | |
| 45 | 48 | useEffect(() => { |
| 46 | 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 | 56 | onRequestClose(); |
| 49 | }, [failed]); | |
| 57 | }, [failed, connection._tag]); | |
| 50 | 58 | useEffect(() => { |
| 51 | 59 | if (!("attachment" in source)) return; |
| 52 | 60 | const controller = new AbortController(); |
apps/mobile/src/components/MediaActionsMenu.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { MenuView } from "@react-native-menu/menu"; | |
| 2 | import type { ReactElement } from "react"; | |
| 3 | import { Platform, View, type PressableProps } from "react-native"; | |
| 4 | ||
| 5 | import type { useMediaActions } from "../lib/mediaActions"; | |
| 6 | import { SymbolView } from "./AppSymbol"; | |
| 7 | import { ControlPillMenu } from "./ControlPill"; | |
| 8 | ||
| 9 | export 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 @@ |
| 1 | import { createContext, useContext } from "react"; | |
| 2 | import { Pressable, View } from "react-native"; | |
| 3 | import ImageViewing from "react-native-image-viewing"; | |
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 5 | ||
| 6 | import { useMediaActions } from "../lib/mediaActions"; | |
| 7 | import { AppText } from "./AppText"; | |
| 8 | import { SymbolView } from "./AppSymbol"; | |
| 9 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | |
| 10 | import { MediaActionsMenu } from "./MediaActionsMenu"; | |
| 11 | import { MediaSourceCaption } from "./MediaSourceCaption"; | |
| 12 | ||
| 13 | type MediaImagePreviewProps = { | |
| 14 | readonly source: ResolvedFilePreviewSource; | |
| 15 | readonly onRequestClose: () => void; | |
| 16 | }; | |
| 17 | ||
| 18 | const ImagePreviewContext = createContext<MediaImagePreviewProps | null>(null); | |
| 19 | ||
| 20 | function 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. */ | |
| 46 | export 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 @@ |
| 1 | import { ScrollView } from "react-native"; | |
| 2 | ||
| 3 | import { AppText } from "./AppText"; | |
| 4 | ||
| 5 | /** Keep the original reference readable without letting long URLs displace the preview. */ | |
| 6 | export 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 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | |
| 2 | import { useEvent } from "expo"; | |
| 3 | import { useVideoPlayer, VideoView } from "expo-video"; | |
| 4 | import { useEffect, useEffectEvent, useRef, useState } from "react"; | |
| 5 | import { ActivityIndicator, AppState, Pressable, View } from "react-native"; | |
| 6 | ||
| 7 | import { AppText } from "./AppText"; | |
| 8 | import { SymbolView } from "./AppSymbol"; | |
| 9 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; | |
| 10 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 11 | import { MediaActionsMenu } from "./MediaActionsMenu"; | |
| 12 | ||
| 13 | /** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ | |
| 14 | function 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 | ||
| 96 | interface 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 | ||
| 109 | function 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 | ||
| 185 | export function MediaVideoPlayer(props: MediaVideoPlayerProps) { | |
| 186 | return <MediaVideoPlayerContent key={props.thumbnailKey} {...props} />; | |
| 187 | } |
apps/mobile/src/components/MediaVideoPreviewModal.tsx created+96| ... | ... | @@ -0,0 +1,96 @@ |
| 1 | import { useEffect } from "react"; | |
| 2 | import { Keyboard, Modal, Pressable, View } from "react-native"; | |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | ||
| 5 | import { useMediaActions } from "../lib/mediaActions"; | |
| 6 | import { MediaActionsMenu } from "./MediaActionsMenu"; | |
| 7 | import { | |
| 8 | mediaVideoPreviewUri, | |
| 9 | mediaVideoThumbnailKey, | |
| 10 | type MediaVideoPreviewSource, | |
| 11 | } from "../lib/videoPreviewSource"; | |
| 12 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; | |
| 13 | import { usePreparedConnection } from "../state/session"; | |
| 14 | import { AppText } from "./AppText"; | |
| 15 | import { SymbolView } from "./AppSymbol"; | |
| 16 | import { MediaVideoPlayer } from "./MediaVideoPlayer"; | |
| 17 | import { MediaSourceCaption } from "./MediaSourceCaption"; | |
| 18 | ||
| 19 | /** Media files stream in place. A client-side copy is made only for an explicit share. */ | |
| 20 | export 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 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | 8 | import { useAssetUrlState } from "../state/assets"; |
| 9 | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; | |
| 10 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 11 | import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; | |
| 11 | 12 | |
| 12 | export type { VideoPreviewSource } from "./VideoPreviewModal"; | |
| 13 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 13 | 14 | |
| 14 | 15 | const NativeControls = requireNativeModule<{ |
| 15 | 16 | presentVideo( |
| ... | ... | @@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{ |
| 22 | 23 | }>("T3NativeControls"); |
| 23 | 24 | |
| 24 | 25 | function NativeVideoPreview(props: { |
| 25 | readonly source: VideoPreviewSource; | |
| 26 | readonly source: AttachmentVideoPreviewSource; | |
| 26 | 27 | readonly onRequestClose: () => void; |
| 27 | 28 | }) { |
| 28 | 29 | const { source } = props; |
| ... | ... | @@ -117,5 +118,8 @@ export function VideoPreviewModal(props: { |
| 117 | 118 | }, [isFocused, hasSource]); |
| 118 | 119 | |
| 119 | 120 | if (!props.source || !isFocused) return null; |
| 121 | if (props.source.type === "media") { | |
| 122 | return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />; | |
| 123 | } | |
| 120 | 124 | return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />; |
| 121 | 125 | } |
apps/mobile/src/components/VideoPreviewModal.tsx+7-11| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 4 | 3 | import { useEvent } from "expo"; |
| 5 | 4 | import { useVideoPlayer, VideoView } from "expo-video"; |
| ... | ... | @@ -19,21 +18,15 @@ import { |
| 19 | 18 | downloadAttachmentForPreview, |
| 20 | 19 | type AttachmentPreviewFile, |
| 21 | 20 | } from "../lib/attachmentDownload"; |
| 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 23 | 21 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 22 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 24 | 23 | import { useAssetUrlState } from "../state/assets"; |
| 25 | 24 | import { usePreparedConnection } from "../state/session"; |
| 26 | 25 | import { SymbolView } from "./AppSymbol"; |
| 27 | 26 | import { AppText } from "./AppText"; |
| 27 | import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; | |
| 28 | 28 | |
| 29 | export type VideoPreviewSource = ( | |
| 30 | | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } | |
| 31 | | { | |
| 32 | readonly type: "remote"; | |
| 33 | readonly environmentId: EnvironmentId; | |
| 34 | readonly attachment: ChatFileAttachment; | |
| 35 | } | |
| 36 | ) & { readonly sourceIdentifier?: string }; | |
| 29 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 37 | 30 | |
| 38 | 31 | function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { |
| 39 | 32 | const player = useVideoPlayer(props.file.uri, (player) => { |
| ... | ... | @@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { |
| 120 | 113 | } |
| 121 | 114 | |
| 122 | 115 | function OpenVideoPreviewModal(props: { |
| 123 | readonly source: VideoPreviewSource; | |
| 116 | readonly source: AttachmentVideoPreviewSource; | |
| 124 | 117 | readonly onRequestClose: () => void; |
| 125 | 118 | }) { |
| 126 | 119 | const { source } = props; |
| ... | ... | @@ -250,6 +243,9 @@ export function VideoPreviewModal(props: { |
| 250 | 243 | }, [isFocused, hasSource, props.onRequestClose]); |
| 251 | 244 | const { source } = props; |
| 252 | 245 | if (source === null || !isFocused) return null; |
| 246 | if (source.type === "media") { | |
| 247 | return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />; | |
| 248 | } | |
| 253 | 249 | const key = |
| 254 | 250 | source.type === "local" |
| 255 | 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 | 11 | export function VideoThumbnailImage(props: { |
| 12 | 12 | readonly cacheKey: string; |
| 13 | 13 | readonly source: string | DraftComposerFileAttachment | null; |
| 14 | readonly contentFit?: "cover" | "contain"; | |
| 14 | 15 | }) { |
| 15 | 16 | const { cacheKey, source } = props; |
| 16 | 17 | const isFocused = useIsFocused(); |
| ... | ... | @@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: { |
| 37 | 38 | <Image |
| 38 | 39 | source={thumbnail} |
| 39 | 40 | style={StyleSheet.absoluteFill} |
| 40 | contentFit="cover" | |
| 41 | contentFit={props.contentFit ?? "cover"} | |
| 41 | 42 | recyclingKey={cacheKey} |
| 42 | 43 | accessible={false} |
| 43 | 44 | /> |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+116-19| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 3 | import type { MenuAction } from "@react-native-menu/menu"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; | |
| 5 | 5 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| ... | ... | @@ -11,6 +11,9 @@ import { |
| 11 | 11 | type ProjectReadFileResult, |
| 12 | 12 | ThreadId, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 15 | import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | |
| 16 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; | |
| 14 | 17 | |
| 15 | 18 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 16 | 19 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 24 | 27 | import { isPdfFile } from "../../lib/filePreview"; |
| 25 | 28 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 26 | 29 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 30 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; | |
| 31 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 27 | 32 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 28 | 33 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 29 | 34 | import { useEnvironmentQuery } from "../../state/query"; |
| ... | ... | @@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 47 | 52 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 48 | 53 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 49 | 54 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 55 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; | |
| 50 | 56 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 51 | 57 | import { |
| 52 | 58 | basename, |
| ... | ... | @@ -54,8 +60,9 @@ import { |
| 54 | 60 | isImagePreviewFile, |
| 55 | 61 | isMarkdownPreviewFile, |
| 56 | 62 | isSvgImagePreviewFile, |
| 63 | isVideoPreviewFile, | |
| 57 | 64 | } from "./filePath"; |
| 58 | import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; | |
| 65 | import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; | |
| 59 | 66 | |
| 60 | 67 | type FileViewMode = "preview" | "source"; |
| 61 | 68 | |
| ... | ... | @@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null { |
| 84 | 91 | } |
| 85 | 92 | |
| 86 | 93 | function 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 | 96 | ? "preview" |
| 89 | 97 | : "source"; |
| 90 | 98 | } |
| ... | ... | @@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode { |
| 92 | 100 | function FileContent(props: { |
| 93 | 101 | readonly activeMode: FileViewMode; |
| 94 | 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 | 107 | readonly fileContents: string | null; |
| 96 | 108 | readonly fileError: string | null; |
| 97 | 109 | readonly relativePath: string; |
| ... | ... | @@ -99,10 +111,25 @@ function FileContent(props: { |
| 99 | 111 | readonly truncated: boolean; |
| 100 | 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 | 116 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 103 | 117 | const isBrowserFile = isBrowserPreviewFile(props.relativePath); |
| 104 | 118 | const isImageFile = isImagePreviewFile(props.relativePath); |
| 105 | 119 | |
| 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 | 133 | if (props.activeMode === "preview" && isImageFile) { |
| 107 | 134 | if (isSvgImagePreviewFile(props.relativePath)) { |
| 108 | 135 | return <WorkspaceFileWebPreview uri={props.previewUri} />; |
| ... | ... | @@ -111,6 +138,7 @@ function FileContent(props: { |
| 111 | 138 | <WorkspaceFileImagePreview |
| 112 | 139 | accessibilityLabel={basename(props.relativePath)} |
| 113 | 140 | uri={props.previewUri} |
| 141 | actionsSource={props.mediaSource} | |
| 114 | 142 | /> |
| 115 | 143 | ); |
| 116 | 144 | } |
| ... | ... | @@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 489 | 517 | readonly mode: FileViewMode; |
| 490 | 518 | } | null>(null); |
| 491 | 519 | const [previewRevision, setPreviewRevision] = useState(0); |
| 520 | const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); | |
| 492 | 521 | const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null); |
| 493 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); | |
| 494 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); | |
| 522 | const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); | |
| 523 | const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath); | |
| 524 | const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath); | |
| 495 | 525 | const canPreview = |
| 496 | relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); | |
| 526 | relativePath !== null && | |
| 527 | (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); | |
| 497 | 528 | const activeMode = |
| 498 | 529 | relativePath !== null && modeOverride?.path === relativePath |
| 499 | 530 | ? modeOverride.mode |
| 500 | 531 | : defaultViewMode(relativePath); |
| 501 | const resolvedActiveMode = canPreview ? activeMode : "source"; | |
| 502 | const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; | |
| 503 | const assetPreviewUri = useWorkspaceFileAssetUrl({ | |
| 532 | const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; | |
| 533 | const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; | |
| 534 | const assetPreview = useWorkspaceFileAssetUrlState({ | |
| 504 | 535 | cwd, |
| 505 | 536 | environmentId, |
| 506 | 537 | relativePath: assetPreviewPath, |
| 507 | 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 | 580 | const previewUri = |
| 510 | 581 | assetPreviewUri === null || previewRevision === 0 |
| 511 | 582 | ? assetPreviewUri |
| 512 | 583 | : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; |
| 513 | 584 | const needsFileContents = |
| 514 | 585 | relativePath !== null && |
| 586 | !isVideoFile && | |
| 515 | 587 | (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); |
| 516 | 588 | const fileQuery = useEnvironmentQuery( |
| 517 | 589 | environmentId !== null && cwd !== null && relativePath !== null && needsFileContents |
| ... | ... | @@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 562 | 634 | |
| 563 | 635 | const fileMenuActions = useMemo(() => { |
| 564 | 636 | if (relativePath === null) return []; |
| 565 | const canToggleMode = canPreview && !isImageFile; | |
| 637 | const canToggleMode = canPreview && !isImageFile && !isVideoFile; | |
| 566 | 638 | return [ |
| 567 | 639 | canToggleMode |
| 568 | 640 | ? ({ |
| ... | ... | @@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 582 | 654 | onPress: () => setModeOverride({ path: relativePath, mode: "source" }), |
| 583 | 655 | } as const) |
| 584 | 656 | : null, |
| 585 | { | |
| 586 | id: "copy-path", | |
| 587 | title: "Copy path", | |
| 588 | icon: "doc.on.doc", | |
| 589 | inline: false, | |
| 590 | onPress: () => copyTextWithHaptic(relativePath), | |
| 591 | } as const, | |
| 657 | ...(mediaSource | |
| 658 | ? mediaActions.actions | |
| 659 | .filter(({ id }) => id !== "open-file") | |
| 660 | .map((action) => ({ | |
| 661 | id: action.id, | |
| 662 | title: action.title, | |
| 663 | icon: | |
| 664 | action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), | |
| 665 | inline: false, | |
| 666 | onPress: action.run, | |
| 667 | })) | |
| 668 | : [ | |
| 669 | { | |
| 670 | id: "copy-path", | |
| 671 | title: "Copy path", | |
| 672 | icon: "doc.on.doc", | |
| 673 | inline: false, | |
| 674 | onPress: () => copyTextWithHaptic(relativePath), | |
| 675 | } as const, | |
| 676 | ]), | |
| 592 | 677 | isPdfFile({ name: relativePath }) && previewUri !== null |
| 593 | 678 | ? ({ |
| 594 | 679 | id: "open-pdf", |
| ... | ... | @@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 612 | 697 | onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), |
| 613 | 698 | } as const) |
| 614 | 699 | : null, |
| 615 | resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) | |
| 700 | resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) | |
| 616 | 701 | ? ({ |
| 617 | 702 | id: "refresh", |
| 618 | 703 | title: "Refresh", |
| 619 | 704 | icon: "arrow.clockwise", |
| 620 | 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 | 710 | } as const) |
| 623 | 711 | : null, |
| 624 | 712 | ].filter((action) => action !== null); |
| 625 | 713 | }, [ |
| 626 | 714 | assetPreviewUri, |
| 715 | assetPreview.refresh, | |
| 627 | 716 | previewUri, |
| 628 | 717 | canPreview, |
| 629 | 718 | isBrowserFile, |
| 630 | 719 | isImageFile, |
| 720 | isVideoFile, | |
| 631 | 721 | relativePath, |
| 632 | 722 | resolvedActiveMode, |
| 723 | mediaSource, | |
| 724 | mediaActions.actions, | |
| 633 | 725 | ]); |
| 634 | 726 | |
| 635 | 727 | const androidFileMenuActions = useMemo<MenuAction[]>( |
| ... | ... | @@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 782 | 874 | </NativeHeaderToolbar.Menu> |
| 783 | 875 | </NativeHeaderToolbar> |
| 784 | 876 | <FileContent |
| 877 | key={previewKey} | |
| 785 | 878 | activeMode={resolvedActiveMode} |
| 786 | 879 | previewUri={previewUri} |
| 880 | previewUnavailable={assetPreview._tag === "Failure"} | |
| 881 | videoSource={videoSource} | |
| 882 | mediaSource={mediaSource} | |
| 883 | resolveVideoUri={assetPreview.refresh} | |
| 787 | 884 | fileContents={fileData?.contents ?? null} |
| 788 | 885 | fileError={fileQuery.error} |
| 789 | 886 | initialLine={targetLine} |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+13-1| ... | ... | @@ -8,14 +8,18 @@ import { EmptyState } from "../../components/EmptyState"; |
| 8 | 8 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; |
| 9 | 9 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 10 | 10 | import { PresentationSource } from "../../components/NativePresentation"; |
| 11 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 12 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | |
| 11 | 13 | |
| 12 | 14 | function ResolvedWorkspaceFileImagePreview(props: { |
| 13 | 15 | readonly accessibilityLabel: string; |
| 14 | 16 | readonly uri: string; |
| 17 | readonly actionsSource?: MediaActionsSource; | |
| 15 | 18 | }) { |
| 16 | 19 | const [loadError, setLoadError] = useState<string | null>(null); |
| 17 | 20 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); |
| 18 | 21 | const sourceIdentifier = useId(); |
| 22 | const mediaActions = useMediaActions(props.actionsSource); | |
| 19 | 23 | const imageSource = useMemo( |
| 20 | 24 | () => ({ uri: props.uri, cache: "force-cache" as const }), |
| 21 | 25 | [props.uri], |
| ... | ... | @@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 34 | 38 | uri: props.uri, |
| 35 | 39 | name: props.accessibilityLabel, |
| 36 | 40 | sourceIdentifier, |
| 41 | actionsSource: props.actionsSource, | |
| 37 | 42 | }) |
| 38 | 43 | } |
| 39 | 44 | > |
| ... | ... | @@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 50 | 55 | /> |
| 51 | 56 | </PresentationSource> |
| 52 | 57 | </Pressable> |
| 53 | ||
| 54 | 58 | {loadError !== null ? ( |
| 55 | 59 | <View className="absolute inset-0 items-center justify-center bg-card px-6"> |
| 56 | 60 | <EmptyState title="Image unavailable" detail={loadError} /> |
| 57 | 61 | </View> |
| 58 | 62 | ) : null} |
| 59 | 63 | |
| 64 | <View className="absolute right-2 top-2"> | |
| 65 | <MediaActionsMenu media={mediaActions} /> | |
| 66 | </View> | |
| 67 | ||
| 60 | 68 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 61 | 69 | </View> |
| 62 | 70 | ); |
| ... | ... | @@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 65 | 73 | function CachedWorkspaceFileImagePreview(props: { |
| 66 | 74 | readonly accessibilityLabel: string; |
| 67 | 75 | readonly uri: string; |
| 76 | readonly actionsSource?: MediaActionsSource; | |
| 68 | 77 | }) { |
| 69 | 78 | const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); |
| 70 | 79 | const imageResult = useAtomValue(imageAtom); |
| ... | ... | @@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: { |
| 93 | 102 | <ResolvedWorkspaceFileImagePreview |
| 94 | 103 | accessibilityLabel={props.accessibilityLabel} |
| 95 | 104 | uri={imageResult.value} |
| 105 | actionsSource={props.actionsSource} | |
| 96 | 106 | /> |
| 97 | 107 | ); |
| 98 | 108 | } |
| ... | ... | @@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: { |
| 100 | 110 | export function WorkspaceFileImagePreview(props: { |
| 101 | 111 | readonly accessibilityLabel: string; |
| 102 | 112 | readonly uri: string | null; |
| 113 | readonly actionsSource?: MediaActionsSource; | |
| 103 | 114 | }) { |
| 104 | 115 | if (props.uri === null) { |
| 105 | 116 | return ( |
| ... | ... | @@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: { |
| 116 | 127 | <CachedWorkspaceFileImagePreview |
| 117 | 128 | accessibilityLabel={props.accessibilityLabel} |
| 118 | 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 @@ |
| 1 | import { useState } from "react"; | |
| 2 | import { View } from "react-native"; | |
| 3 | ||
| 4 | import { EmptyState } from "../../components/EmptyState"; | |
| 5 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | |
| 6 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 7 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; | |
| 8 | ||
| 9 | /** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ | |
| 10 | export 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 | 1 | import { |
| 2 | 2 | isWorkspaceBrowserPreviewPath, |
| 3 | 3 | isWorkspaceImagePreviewPath, |
| 4 | isWorkspaceVideoPreviewPath, | |
| 4 | 5 | } from "@t3tools/shared/filePreview"; |
| 5 | 6 | |
| 6 | 7 | export interface FileBreadcrumb { |
| ... | ... | @@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean { |
| 95 | 96 | return isWorkspaceImagePreviewPath(path); |
| 96 | 97 | } |
| 97 | 98 | |
| 99 | export function isVideoPreviewFile(path: string): boolean { | |
| 100 | return isWorkspaceVideoPreviewPath(path); | |
| 101 | } | |
| 102 | ||
| 98 | 103 | export function isSvgImagePreviewFile(path: string): boolean { |
| 99 | 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 | 3 | |
| 4 | 4 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 5 | 5 | import { projectEnvironment } from "../../state/projects"; |
| 6 | import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; | |
| 6 | import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath"; | |
| 7 | 7 | import { prepareSourceFileDocument } from "./source-file-document"; |
| 8 | 8 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 9 | 9 | import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; |
| ... | ... | @@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: { |
| 25 | 25 | readonly relativePath: string; |
| 26 | 26 | readonly theme: ReviewDiffTheme; |
| 27 | 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 | 33 | return; |
| 30 | 34 | } |
| 31 | 35 |
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+17-13| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useMemo } from "react"; |
| 3 | 3 | |
| 4 | import { useAssetUrl } from "../../state/assets"; | |
| 5 | import { resolveWorkspaceFilePath } from "./filePath"; | |
| 4 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; | |
| 5 | import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; | |
| 6 | 6 | |
| 7 | export function useWorkspaceFileAssetUrl(props: { | |
| 7 | export function useWorkspaceFileAssetUrlState(props: { | |
| 8 | 8 | readonly cwd: string | null; |
| 9 | 9 | readonly environmentId: EnvironmentId | null; |
| 10 | 10 | readonly relativePath: string | null; |
| ... | ... | @@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: { |
| 18 | 18 | [props.cwd, props.relativePath], |
| 19 | 19 | ); |
| 20 | 20 | |
| 21 | return useAssetUrl( | |
| 22 | props.environmentId, | |
| 23 | absolutePath !== null && props.threadId !== null | |
| 24 | ? { | |
| 25 | _tag: "workspace-file", | |
| 26 | threadId: props.threadId, | |
| 27 | path: absolutePath, | |
| 28 | } | |
| 29 | : null, | |
| 21 | const resource = useMemo<AssetResource | null>( | |
| 22 | () => | |
| 23 | absolutePath !== null && props.threadId !== null | |
| 24 | ? { | |
| 25 | _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", | |
| 26 | threadId: props.threadId, | |
| 27 | path: absolutePath, | |
| 28 | } | |
| 29 | : null, | |
| 30 | [absolutePath, props.threadId], | |
| 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 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | |
| 3 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | |
| 4 | 4 | import type { |
| 5 | 5 | AssetResource, |
| 6 | 6 | ChatAttachment, |
| ... | ... | @@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 33 | 33 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 34 | 34 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 35 | 35 | import { |
| 36 | createContext, | |
| 36 | 37 | memo, |
| 37 | 38 | useCallback, |
| 38 | 39 | useContext, |
| ... | ... | @@ -97,6 +98,15 @@ import { |
| 97 | 98 | import { AppText as Text } from "../../components/AppText"; |
| 98 | 99 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 99 | 100 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; |
| 101 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | |
| 102 | import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; | |
| 103 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 104 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | |
| 105 | import { | |
| 106 | mediaVideoPreviewUri, | |
| 107 | mediaVideoThumbnailKey, | |
| 108 | type MediaVideoPreviewSource, | |
| 109 | } from "../../lib/videoPreviewSource"; | |
| 100 | 110 | import { CopyTextButton } from "../../components/CopyTextButton"; |
| 101 | 111 | import { |
| 102 | 112 | parseReviewCommentMessageSegments, |
| ... | ... | @@ -123,7 +133,11 @@ import { |
| 123 | 133 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 124 | 134 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 125 | 135 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 126 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | |
| 136 | import { | |
| 137 | normalizeNativeMarkdownUrl, | |
| 138 | resolveMarkdownInlineCodePresentation, | |
| 139 | resolveMarkdownLinkPresentation, | |
| 140 | } from "@t3tools/mobile-markdown-text/links"; | |
| 127 | 141 | import { |
| 128 | 142 | deriveThreadFeedPresentation, |
| 129 | 143 | type ThreadFeedEntry, |
| ... | ... | @@ -143,7 +157,12 @@ import { |
| 143 | 157 | WORK_GROUP_TOGGLE_HEIGHT, |
| 144 | 158 | } from "./thread-work-log"; |
| 145 | 159 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 146 | import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; | |
| 160 | import { | |
| 161 | assetEnvironment, | |
| 162 | useAssetUrl, | |
| 163 | useAssetUrlState, | |
| 164 | useRefreshAssetUrl, | |
| 165 | } from "../../state/assets"; | |
| 147 | 166 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 148 | 167 | import { usePreparedConnection } from "../../state/session"; |
| 149 | 168 | import * as Option from "effect/Option"; |
| ... | ... | @@ -272,6 +291,7 @@ function MessageAttachmentFile(props: { |
| 272 | 291 | }) { |
| 273 | 292 | const sourceIdentifier = useId(); |
| 274 | 293 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 294 | refresh: true, | |
| 275 | 295 | reportFailure: false, |
| 276 | 296 | }); |
| 277 | 297 | const preparedConnection = usePreparedConnection(props.environmentId); |
| ... | ... | @@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: { |
| 453 | 473 | readonly sourceKey: string; |
| 454 | 474 | readonly unavailable: boolean; |
| 455 | 475 | readonly alt: string | null; |
| 476 | readonly actionsSource?: MediaActionsSource; | |
| 456 | 477 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 457 | 478 | }) { |
| 458 | 479 | const sourceIdentifier = useId(); |
| 480 | const mediaActions = useMediaActions(props.actionsSource); | |
| 459 | 481 | const [availableWidth, setAvailableWidth] = useState(0); |
| 460 | 482 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 461 | 483 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| ... | ... | @@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: { |
| 498 | 520 | ) : ( |
| 499 | 521 | <ActivityIndicator /> |
| 500 | 522 | )} |
| 523 | {props.actionsSource ? ( | |
| 524 | <View className="absolute right-1 top-1"> | |
| 525 | <MediaActionsMenu media={mediaActions} /> | |
| 526 | </View> | |
| 527 | ) : null} | |
| 501 | 528 | </View> |
| 502 | 529 | ) : ( |
| 503 | 530 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 504 | <Pressable | |
| 505 | accessibilityRole="imagebutton" | |
| 506 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 507 | onPress={() => | |
| 508 | props.onPressPreview({ | |
| 509 | kind: "image", | |
| 510 | uri: props.uri!, | |
| 511 | name: props.alt ?? "Image", | |
| 512 | sourceIdentifier, | |
| 513 | }) | |
| 514 | } | |
| 515 | style={{ alignSelf: "flex-start" }} | |
| 516 | > | |
| 517 | <View | |
| 518 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 519 | style={{ | |
| 520 | ...frameStyle, | |
| 521 | }} | |
| 522 | > | |
| 523 | <ThreadMarkdownImageRequest | |
| 524 | key={props.uri} | |
| 525 | uri={props.uri} | |
| 526 | onLoad={setSourceSize} | |
| 527 | onError={() => setFailedUri(props.uri)} | |
| 528 | /> | |
| 529 | </View> | |
| 530 | </Pressable> | |
| 531 | <View> | |
| 532 | <MediaActionsMenu media={mediaActions}> | |
| 533 | <Pressable | |
| 534 | accessibilityRole="imagebutton" | |
| 535 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 536 | onPress={() => | |
| 537 | props.onPressPreview({ | |
| 538 | kind: "image", | |
| 539 | uri: props.uri!, | |
| 540 | name: props.alt ?? "Image", | |
| 541 | sourceIdentifier, | |
| 542 | actionsSource: props.actionsSource, | |
| 543 | }) | |
| 544 | } | |
| 545 | style={{ alignSelf: "flex-start" }} | |
| 546 | > | |
| 547 | <View | |
| 548 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 549 | style={{ | |
| 550 | ...frameStyle, | |
| 551 | }} | |
| 552 | > | |
| 553 | <ThreadMarkdownImageRequest | |
| 554 | key={props.uri} | |
| 555 | uri={props.uri} | |
| 556 | onLoad={setSourceSize} | |
| 557 | onError={() => setFailedUri(props.uri)} | |
| 558 | /> | |
| 559 | </View> | |
| 560 | </Pressable> | |
| 561 | </MediaActionsMenu> | |
| 562 | {props.actionsSource ? ( | |
| 563 | <View className="absolute right-1 top-1"> | |
| 564 | <MediaActionsMenu media={mediaActions} /> | |
| 565 | </View> | |
| 566 | ) : null} | |
| 567 | </View> | |
| 531 | 568 | </PresentationSource> |
| 532 | 569 | )} |
| 533 | 570 | {props.alt ? ( |
| ... | ... | @@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: { |
| 574 | 611 | /** Environment-hosted image that loads through a signed asset URL. */ |
| 575 | 612 | function ThreadMarkdownImage(props: { |
| 576 | 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 | 615 | readonly alt: string | null; |
| 579 | 616 | readonly srcFragment?: string; |
| 617 | readonly actionsSource?: MediaActionsSource; | |
| 580 | 618 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 581 | 619 | }) { |
| 582 | 620 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| ... | ... | @@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: { |
| 591 | 629 | } |
| 592 | 630 | unavailable={assetUrl._tag === "Failure"} |
| 593 | 631 | alt={props.alt} |
| 632 | actionsSource={props.actionsSource} | |
| 594 | 633 | onPressPreview={props.onPressPreview} |
| 595 | 634 | /> |
| 596 | 635 | ); |
| 597 | 636 | } |
| 598 | 637 | |
| 638 | const ThreadMediaVisibleContext = createContext(false); | |
| 639 | // LegendList only computes hook visibility when the list has a viewability config. | |
| 640 | const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; | |
| 641 | ||
| 642 | function 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 | ||
| 650 | function 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 | ||
| 599 | 685 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { |
| 600 | 686 | return ( |
| 601 | 687 | <ThreadMarkdownImageView |
| ... | ... | @@ -636,6 +722,7 @@ interface ReviewCommentColors { |
| 636 | 722 | } |
| 637 | 723 | |
| 638 | 724 | const failedMarkdownFaviconHosts = new Set<string>(); |
| 725 | const MarkdownLinkLabelContext = createContext(false); | |
| 639 | 726 | const markdownLinkStyles = StyleSheet.create({ |
| 640 | 727 | inlineIcon: { |
| 641 | 728 | width: 14, |
| ... | ... | @@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 653 | 740 | readonly color: string; |
| 654 | 741 | readonly host: string; |
| 655 | 742 | readonly href: string; |
| 743 | readonly onPress: (href: string) => void; | |
| 656 | 744 | }) { |
| 657 | 745 | const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); |
| 658 | 746 | |
| 659 | 747 | return ( |
| 660 | 748 | <NativeText |
| 661 | 749 | className="font-sans" |
| 662 | onPress={() => { | |
| 663 | void tryOpenExternalUrl(props.href, "markdown-link"); | |
| 664 | }} | |
| 750 | onPress={() => props.onPress(props.href)} | |
| 665 | 751 | style={{ |
| 666 | 752 | color: props.color, |
| 667 | 753 | textDecorationLine: "none", |
| ... | ... | @@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 686 | 772 | ); |
| 687 | 773 | }); |
| 688 | 774 | |
| 775 | function 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 | ||
| 689 | 806 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< |
| 690 | 807 | CodexArtifactTemplate["artifactKind"], |
| 691 | 808 | AppSymbolName |
| ... | ... | @@ -1078,30 +1195,35 @@ function useMarkdownStyles( |
| 1078 | 1195 | } |
| 1079 | 1196 | if (presentation.kind === "external") { |
| 1080 | 1197 | return ( |
| 1081 | <MarkdownExternalLink | |
| 1082 | href={presentation.href} | |
| 1083 | host={presentation.host} | |
| 1084 | color={markdownLinkColor} | |
| 1085 | > | |
| 1086 | {children} | |
| 1087 | </MarkdownExternalLink> | |
| 1198 | <MarkdownLinkLabelContext.Provider value> | |
| 1199 | <MarkdownExternalLink | |
| 1200 | href={presentation.href} | |
| 1201 | host={presentation.host} | |
| 1202 | color={markdownLinkColor} | |
| 1203 | onPress={onLinkPress} | |
| 1204 | > | |
| 1205 | {children} | |
| 1206 | </MarkdownExternalLink> | |
| 1207 | </MarkdownLinkLabelContext.Provider> | |
| 1088 | 1208 | ); |
| 1089 | 1209 | } |
| 1090 | 1210 | const linkHref = presentation.href; |
| 1091 | 1211 | return ( |
| 1092 | <NativeText | |
| 1093 | className="underline" | |
| 1094 | onPress={ | |
| 1095 | linkHref | |
| 1096 | ? () => { | |
| 1097 | void tryOpenExternalUrl(linkHref, "markdown-link"); | |
| 1098 | } | |
| 1099 | : undefined | |
| 1100 | } | |
| 1101 | style={{ color: markdownLinkColor }} | |
| 1102 | > | |
| 1103 | {children} | |
| 1104 | </NativeText> | |
| 1212 | <MarkdownLinkLabelContext.Provider value> | |
| 1213 | <NativeText | |
| 1214 | className="underline" | |
| 1215 | onPress={ | |
| 1216 | linkHref | |
| 1217 | ? () => { | |
| 1218 | void tryOpenExternalUrl(linkHref, "markdown-link"); | |
| 1219 | } | |
| 1220 | : undefined | |
| 1221 | } | |
| 1222 | style={{ color: markdownLinkColor }} | |
| 1223 | > | |
| 1224 | {children} | |
| 1225 | </NativeText> | |
| 1226 | </MarkdownLinkLabelContext.Provider> | |
| 1105 | 1227 | ); |
| 1106 | 1228 | }, |
| 1107 | 1229 | list: ({ node, Renderer, ordered = false, start = 1 }) => ( |
| ... | ... | @@ -1144,21 +1266,16 @@ function useMarkdownStyles( |
| 1144 | 1266 | title: node.title ?? null, |
| 1145 | 1267 | }) ?? undefined) |
| 1146 | 1268 | : undefined, |
| 1147 | code_inline: ({ content }) => { | |
| 1148 | const value = content ?? ""; | |
| 1149 | return ( | |
| 1150 | <NativeText | |
| 1151 | className="font-mono" | |
| 1152 | style={{ | |
| 1153 | color: inlineCodeTextColor, | |
| 1154 | fontSize: markdownFontSizes.codeBlockFontSize, | |
| 1155 | lineHeight: markdownFontSizes.bodyLineHeight, | |
| 1156 | }} | |
| 1157 | > | |
| 1158 | {value} | |
| 1159 | </NativeText> | |
| 1160 | ); | |
| 1161 | }, | |
| 1269 | code_inline: ({ content }) => ( | |
| 1270 | <MarkdownInlineCode | |
| 1271 | content={content ?? ""} | |
| 1272 | textColor={inlineTextColor} | |
| 1273 | codeColor={inlineCodeTextColor} | |
| 1274 | fontSize={markdownFontSizes.codeBlockFontSize} | |
| 1275 | lineHeight={markdownFontSizes.bodyLineHeight} | |
| 1276 | onLinkPress={onLinkPress} | |
| 1277 | /> | |
| 1278 | ), | |
| 1162 | 1279 | ...(preserveSoftBreaks |
| 1163 | 1280 | ? { |
| 1164 | 1281 | soft_break: () => <NativeText>{"\n"}</NativeText>, |
| ... | ... | @@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1954 | 2071 | path: relativePath.split("/").filter((segment) => segment.length > 0), |
| 1955 | 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 | 2090 | return; |
| 1959 | 2091 | } |
| 1960 | 2092 | |
| 1961 | if (presentation.href) { | |
| 2093 | if (presentation.kind !== "file" && presentation.href) { | |
| 1962 | 2094 | if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { |
| 1963 | 2095 | setExpandedFile( |
| 1964 | 2096 | (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, |
| ... | ... | @@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1972 | 2104 | ); |
| 1973 | 2105 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 1974 | 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 | 2122 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); |
| 1976 | 2123 | if (imageSource._tag === "Direct") { |
| 1977 | 2124 | return ( |
| 1978 | 2125 | <ThreadMarkdownImageView |
| 1979 | uri={imageSource.uri} | |
| 2126 | uri={normalizeNativeMarkdownUrl(imageSource.uri)} | |
| 1980 | 2127 | sourceKey={imageSource.uri} |
| 1981 | 2128 | unavailable={false} |
| 1982 | 2129 | alt={image.alt} |
| 2130 | actionsSource={media?.source.actionsSource} | |
| 1983 | 2131 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 1984 | 2132 | /> |
| 1985 | 2133 | ); |
| ... | ... | @@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1991 | 2139 | <ThreadMarkdownImage |
| 1992 | 2140 | environmentId={props.environmentId} |
| 1993 | 2141 | resource={{ |
| 1994 | _tag: "workspace-file", | |
| 2142 | _tag: "media-file", | |
| 1995 | 2143 | threadId: props.threadId, |
| 1996 | 2144 | path: imageSource.path, |
| 1997 | 2145 | }} |
| 1998 | 2146 | alt={image.alt} |
| 1999 | 2147 | srcFragment={markdownImageSourceFragment(image.href)} |
| 2148 | actionsSource={media?.source.actionsSource} | |
| 2000 | 2149 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2001 | 2150 | /> |
| 2002 | 2151 | ); |
| ... | ... | @@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2009 | 2158 | threadId: props.threadId, |
| 2010 | 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 | 2170 | return viewedImage ? ( |
| 2013 | 2171 | <ThreadMarkdownImage |
| 2014 | 2172 | environmentId={props.environmentId} |
| 2015 | 2173 | resource={viewedImage.resource} |
| 2016 | 2174 | alt={viewedImage.alt} |
| 2017 | 2175 | srcFragment={viewedImage.srcFragment} |
| 2176 | actionsSource={ | |
| 2177 | actionsSource && "resource" in actionsSource | |
| 2178 | ? { ...actionsSource, resource: viewedImage.resource } | |
| 2179 | : undefined | |
| 2180 | } | |
| 2018 | 2181 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2019 | 2182 | /> |
| 2020 | 2183 | ) : null; |
| ... | ... | @@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2438 | 2601 | key={info.item.id} |
| 2439 | 2602 | entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} |
| 2440 | 2603 | > |
| 2441 | {renderFeedEntry(info, { | |
| 2442 | environmentId: props.environmentId, | |
| 2443 | copiedRowId, | |
| 2444 | expandedWorkRows, | |
| 2445 | terminalAssistantMessageIds, | |
| 2446 | unsettledTurnId, | |
| 2447 | onCopyWorkRow, | |
| 2448 | onToggleWorkGroup, | |
| 2449 | onToggleWorkRow, | |
| 2450 | onToggleTurnFold, | |
| 2451 | onPressPreview, | |
| 2452 | onPressVideo, | |
| 2453 | onMarkdownLinkPress, | |
| 2454 | renderMarkdownImage, | |
| 2455 | renderViewedImage, | |
| 2456 | iconSubtleColor, | |
| 2457 | userBubbleColor, | |
| 2458 | markdownStyles, | |
| 2459 | reviewCommentColors, | |
| 2460 | reviewCommentBubbleWidth, | |
| 2461 | userBubbleMaxWidth, | |
| 2462 | skills: props.skills, | |
| 2463 | onUseArtifactTemplate: props.onUseArtifactTemplate, | |
| 2464 | })} | |
| 2604 | <ThreadMediaVisibility> | |
| 2605 | {renderFeedEntry(info, { | |
| 2606 | environmentId: props.environmentId, | |
| 2607 | copiedRowId, | |
| 2608 | expandedWorkRows, | |
| 2609 | terminalAssistantMessageIds, | |
| 2610 | unsettledTurnId, | |
| 2611 | onCopyWorkRow, | |
| 2612 | onToggleWorkGroup, | |
| 2613 | onToggleWorkRow, | |
| 2614 | onToggleTurnFold, | |
| 2615 | onPressPreview, | |
| 2616 | onPressVideo, | |
| 2617 | onMarkdownLinkPress, | |
| 2618 | renderMarkdownImage, | |
| 2619 | renderViewedImage, | |
| 2620 | iconSubtleColor, | |
| 2621 | userBubbleColor, | |
| 2622 | markdownStyles, | |
| 2623 | reviewCommentColors, | |
| 2624 | reviewCommentBubbleWidth, | |
| 2625 | userBubbleMaxWidth, | |
| 2626 | skills: props.skills, | |
| 2627 | onUseArtifactTemplate: props.onUseArtifactTemplate, | |
| 2628 | })} | |
| 2629 | </ThreadMediaVisibility> | |
| 2465 | 2630 | </Animated.View> |
| 2466 | 2631 | ), |
| 2467 | 2632 | [ |
| ... | ... | @@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2586 | 2751 | data={presentedFeed} |
| 2587 | 2752 | extraData={listAppearanceData} |
| 2588 | 2753 | renderItem={renderItem} |
| 2754 | viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} | |
| 2589 | 2755 | keyExtractor={(entry) => entry.id} |
| 2590 | 2756 | getItemType={(entry) => |
| 2591 | 2757 | 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 | 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 4 | 4 | |
| 5 | 5 | describe("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 | 22 | it("extracts external link hosts", () => { |
| 7 | 23 | expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ |
| 8 | 24 | kind: "external", |
| ... | ... | @@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => { |
| 11 | 27 | }); |
| 12 | 28 | }); |
| 13 | 29 | |
| 14 | it("renders file URLs as basename pills with positions", () => { | |
| 15 | expect( | |
| 16 | resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), | |
| 17 | ).toEqual({ | |
| 30 | it.each([ | |
| 31 | ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], | |
| 32 | ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], | |
| 33 | ])("preserves the file URL path and position for %s", (href, path) => { | |
| 34 | expect(resolveMarkdownLinkPresentation(href)).toEqual({ | |
| 18 | 35 | kind: "file", |
| 19 | href: "file:///Users/julius/project/src/main.ts#L42C7", | |
| 36 | href, | |
| 20 | 37 | icon: "typescript", |
| 21 | 38 | label: "main.ts:42:7", |
| 22 | path: "/Users/julius/project/src/main.ts", | |
| 39 | path, | |
| 23 | 40 | line: 42, |
| 24 | 41 | column: 7, |
| 25 | 42 | }); |
apps/mobile/src/lib/markdownMedia.test.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { resolveMarkdownMediaPreview } from "./markdownMedia"; | |
| 5 | ||
| 6 | const input = { | |
| 7 | environmentId: EnvironmentId.make("environment-1"), | |
| 8 | threadId: ThreadId.make("thread-1"), | |
| 9 | workspaceRoot: "/repo", | |
| 10 | }; | |
| 11 | ||
| 12 | describe("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 @@ |
| 1 | import { | |
| 2 | classifyMarkdownImageSource, | |
| 3 | markdownImageSourceFragment, | |
| 4 | } from "@t3tools/client-runtime/markdown-images"; | |
| 5 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | |
| 7 | import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | |
| 8 | import { | |
| 9 | mediaFileReference, | |
| 10 | mediaReferenceFileName, | |
| 11 | mediaUrlReference, | |
| 12 | } from "@t3tools/client-runtime/media-reference"; | |
| 13 | ||
| 14 | import type { FilePreviewSource } from "../components/FilePreviewModal"; | |
| 15 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; | |
| 16 | import type { MediaActionsSource } from "./mediaActions"; | |
| 17 | ||
| 18 | /** Resolves only explicit media references. Ordinary links keep their existing navigation. */ | |
| 19 | export 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 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; | |
| 3 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | |
| 5 | import { useEffect, useRef, useState } from "react"; | |
| 6 | import { Alert } from "react-native"; | |
| 7 | ||
| 8 | import { useRefreshAssetUrl } from "../state/assets"; | |
| 9 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; | |
| 10 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | |
| 11 | ||
| 12 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ | |
| 13 | export 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 | ||
| 26 | export 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 | 11 | } from "@t3tools/mobile-markdown-text/markdown"; |
| 12 | 12 | |
| 13 | 13 | describe("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 | 51 | it("preserves inline emphasis and code styles", () => { |
| 15 | 52 | const node: MarkdownNode = { |
| 16 | 53 | type: "paragraph", |
apps/mobile/src/lib/videoPreviewSource.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | |
| 4 | import type { MediaActionsSource } from "./mediaActions"; | |
| 5 | ||
| 6 | export 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. */ | |
| 22 | export 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. */ | |
| 31 | export 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 | ||
| 45 | export 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 | ||
| 54 | export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; |
apps/mobile/src/state/assets.ts+22| ... | ... | @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 3 | 3 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 4 | 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 5 | import { useCallback } from "react"; | |
| 5 | 6 | |
| 6 | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 7 | 8 | import { usePreparedConnection } from "./session"; |
| 9 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | |
| 8 | 10 | |
| 9 | 11 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 10 | 12 | |
| ... | ... | @@ -44,3 +46,23 @@ export function useAssetUrl( |
| 44 | 46 | const state = useAssetUrlState(environmentId, resource); |
| 45 | 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. */ | |
| 51 | export 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 | 1 | import { RegistryContext } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | executeAtomQuery, |
| 4 | type AtomCommandOptions, | |
| 4 | type AtomQueryOptions, | |
| 5 | 5 | type AtomCommandResult, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 7 | import { AsyncResult, type Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; |
| 9 | 9 | |
| 10 | 10 | export function useAtomQueryRunner<T, A, E>( |
| 11 | 11 | family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>, |
| 12 | options?: string | AtomCommandOptions, | |
| 12 | options?: string | AtomQueryOptions, | |
| 13 | 13 | ): (target: T) => Promise<AtomCommandResult<A, E>> { |
| 14 | 14 | const registry = useContext(RegistryContext); |
| 15 | 15 | const explicitLabel = typeof options === "string" ? options : options?.label; |
| 16 | 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); |
| 17 | 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); |
| 18 | const refresh = typeof options === "string" ? false : (options?.refresh ?? false); | |
| 18 | 19 | |
| 19 | 20 | return useCallback( |
| 20 | 21 | (target: T) => { |
| ... | ... | @@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>( |
| 23 | 24 | label: explicitLabel ?? atom.label?.[0] ?? "atom query", |
| 24 | 25 | reportFailure, |
| 25 | 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 | // @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | |
| 4 | import * as NodeFSP from "node:fs/promises"; | |
| 2 | 5 | import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; |
| 3 | 6 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 4 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| ... | ... | @@ -9,18 +12,28 @@ import * as Layer from "effect/Layer"; |
| 9 | 12 | import * as Path from "effect/Path"; |
| 10 | 13 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 16 | import { vi } from "vite-plus/test"; | |
| 12 | 17 | |
| 13 | 18 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 14 | 19 | import * as ServerConfig from "../config.ts"; |
| 15 | 20 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 16 | 21 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 17 | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | import { assetFileResponse } from "../http.ts"; | |
| 18 | 24 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; |
| 25 | import { openMediaFile } from "./MediaFile.ts"; | |
| 26 | ||
| 27 | vi.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 | }); | |
| 19 | 31 | |
| 20 | 32 | const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 21 | 33 | prefix: "t3-asset-access-test-", |
| 22 | 34 | }); |
| 23 | 35 | const testLayer = Layer.mergeAll( |
| 36 | NodeHttpPlatform.layer, | |
| 24 | 37 | configLayer, |
| 25 | 38 | WorkspacePaths.layer, |
| 26 | 39 | ProjectFaviconResolver.layer.pipe( |
| ... | ... | @@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll( |
| 31 | 44 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 32 | 45 | |
| 33 | 46 | describe("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 | 369 | it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => |
| 35 | 370 | Effect.gen(function* () { |
| 36 | 371 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/assets/AssetAccess.ts+84| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | import { |
| 17 | 17 | isWorkspaceImagePreviewPath, |
| 18 | 18 | isWorkspacePreviewEntryPath, |
| 19 | mediaMimeTypeFromExtension, | |
| 19 | 20 | WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, |
| 20 | 21 | WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, |
| 21 | 22 | } from "@t3tools/shared/filePreview"; |
| ... | ... | @@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta |
| 41 | 42 | import * as ServerConfig from "../config.ts"; |
| 42 | 43 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 43 | 44 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 45 | import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; | |
| 44 | 46 | |
| 45 | 47 | export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| 46 | 48 | |
| ... | ... | @@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([ |
| 76 | 78 | relativePath: Schema.String, |
| 77 | 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 | 89 | Schema.Struct({ |
| 80 | 90 | version: Schema.Literal(1), |
| 81 | 91 | kind: Schema.Literal("attachment"), |
| ... | ... | @@ -115,6 +125,7 @@ export type ResolvedAsset = { |
| 115 | 125 | readonly download?: boolean; |
| 116 | 126 | readonly fileName?: string; |
| 117 | 127 | readonly mimeType?: string; |
| 128 | readonly file?: OpenMediaFile; | |
| 118 | 129 | }; |
| 119 | 130 | |
| 120 | 131 | function decodeClaims(encodedPayload: string): AssetClaims | null { |
| ... | ... | @@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 211 | 222 | let sourcePath: string | undefined; |
| 212 | 223 | |
| 213 | 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 | 274 | case "workspace-file": { |
| 215 | 275 | if (!input.workspaceRoot) { |
| 216 | 276 | return yield* new AssetWorkspaceContextNotFoundError({ |
| ... | ... | @@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 526 | 586 | const decodedPath = decodeRelativePath(relativePath); |
| 527 | 587 | if (decodedPath === null) return null; |
| 528 | 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 | 613 | if (claims.kind === "workspace-file-exact") { |
| 530 | 614 | if (decodedPath !== path.basename(claims.relativePath)) return null; |
| 531 | 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. | |
| 3 | import * as NodeFS from "node:fs"; | |
| 4 | import * as NodeFSP from "node:fs/promises"; | |
| 5 | ||
| 6 | import * as NodeStream from "@effect/platform-node/NodeStream"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | class 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 | ||
| 22 | class 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. */ | |
| 35 | export 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. */ | |
| 41 | export 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 | ||
| 89 | export 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 | ||
| 99 | export 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 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { describe } from "vite-plus/test"; | |
| 2 | import { describe, vi } from "vite-plus/test"; | |
| 3 | 3 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Path from "effect/Path"; |
| 9 | 9 | import { HttpServerResponse } from "effect/unstable/http"; |
| 10 | import { openMediaFile } from "./assets/MediaFile.ts"; | |
| 10 | 11 | |
| 11 | 12 | import { |
| 12 | 13 | assetResponseHeaders, |
| ... | ... | @@ -19,6 +20,179 @@ import { |
| 19 | 20 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); |
| 20 | 21 | |
| 21 | 22 | describe("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 | 196 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => |
| 23 | 197 | Effect.gen(function* () { |
| 24 | 198 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/http.ts+46-10| ... | ... | @@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability"; |
| 29 | 29 | |
| 30 | 30 | import * as ServerConfig from "./config.ts"; |
| 31 | 31 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; |
| 32 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; | |
| 32 | 33 | import { |
| 33 | 34 | ATTACHMENT_UPLOAD_ROUTE_PREFIX, |
| 34 | 35 | storeAttachmentUpload, |
| ... | ... | @@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) { |
| 124 | 125 | } |
| 125 | 126 | const start = first ?? (last! >= size ? 0n : size - last!); |
| 126 | 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 | 131 | return { |
| 128 | 132 | _tag: "Range" as const, |
| 129 | 133 | offset: start, |
| ... | ... | @@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( |
| 138 | 142 | readonly download?: boolean; |
| 139 | 143 | readonly fileName?: string; |
| 140 | 144 | readonly mimeType?: string; |
| 145 | readonly file?: OpenMediaFile; | |
| 141 | 146 | }, |
| 142 | 147 | rangeHeader?: string, |
| 143 | 148 | ifRangeHeader?: string, |
| 149 | method: "GET" | "HEAD" = "GET", | |
| 144 | 150 | ) { |
| 145 | 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 | 164 | headers["Accept-Ranges"] = "bytes"; |
| 148 | 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 | 167 | const fs = yield* FileSystem.FileSystem; |
| 151 | const info = yield* fs.stat(asset.path); | |
| 168 | const info = mediaInfo ?? (yield* fs.stat(asset.path)); | |
| 152 | 169 | const range = assetByteRange(rangeHeader, info.size); |
| 153 | 170 | if (range?._tag === "Unsatisfiable") { |
| 154 | 171 | return HttpServerResponse.empty({ |
| ... | ... | @@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( |
| 157 | 174 | }); |
| 158 | 175 | } |
| 159 | 176 | if (range?._tag === "Range") { |
| 160 | return yield* HttpServerResponse.file(asset.path, { | |
| 161 | status: 206, | |
| 162 | offset: range.offset, | |
| 163 | bytesToRead: range.bytesToRead, | |
| 164 | headers: { ...headers, "Content-Range": range.contentRange }, | |
| 165 | }); | |
| 177 | status = 206; | |
| 178 | offset = range.offset; | |
| 179 | bytesToRead = range.bytesToRead; | |
| 180 | headers["Content-Range"] = range.contentRange; | |
| 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 | }); |
| 171 | 206 | |
| 172 | 207 | export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { |
| ... | ... | @@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add( |
| 338 | 373 | asset, |
| 339 | 374 | request.method === "GET" ? request.headers.range : undefined, |
| 340 | 375 | request.headers["if-range"], |
| 376 | request.method === "HEAD" ? "HEAD" : "GET", | |
| 341 | 377 | ).pipe( |
| 342 | 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | 4 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 4 | 5 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { useMemo } from "react"; | |
| 6 | import { useCallback, useMemo } from "react"; | |
| 6 | 7 | |
| 7 | 8 | import { assetEnvironment } from "~/state/assets"; |
| 8 | 9 | import { usePreparedConnection } from "~/state/session"; |
| 10 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | |
| 9 | 11 | |
| 10 | 12 | export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 11 | 13 | |
| ... | ... | @@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc |
| 49 | 51 | return result.url; |
| 50 | 52 | } |
| 51 | 53 | |
| 54 | /** Re-mints an exact-file capability after a file change or an explicit retry. */ | |
| 55 | export 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 | ||
| 52 | 69 | export function useAssetUrls( |
| 53 | 70 | environmentId: EnvironmentId, |
| 54 | 71 | resources: ReadonlyArray<AssetResource>, |
apps/web/src/components/ChatMarkdown.tsx+357-60| ... | ... | @@ -42,6 +42,9 @@ import { |
| 42 | 42 | classifyMarkdownImageSource, |
| 43 | 43 | markdownImageSourceFragment, |
| 44 | 44 | } from "@t3tools/client-runtime/markdown-images"; |
| 45 | import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; | |
| 46 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | |
| 47 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | |
| 45 | 48 | import * as Cause from "effect/Cause"; |
| 46 | 49 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 47 | 50 | import React, { |
| ... | ... | @@ -76,7 +79,14 @@ import { |
| 76 | 79 | renderCodexFileCitationsAsMarkdown, |
| 77 | 80 | } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 78 | 81 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 79 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 82 | import { | |
| 83 | resolveMarkdownMediaPreview, | |
| 84 | type ExpandedImagePreview, | |
| 85 | } from "./chat/ExpandedImagePreview"; | |
| 86 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | |
| 87 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; | |
| 88 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; | |
| 89 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; | |
| 80 | 90 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| 81 | 91 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 82 | 92 | import { |
| ... | ... | @@ -126,7 +136,7 @@ import { |
| 126 | 136 | type MarkdownFileLinkMeta, |
| 127 | 137 | } from "../markdown-links"; |
| 128 | 138 | import { readLocalApi } from "../localApi"; |
| 129 | import { useAssetUrlState } from "../assets/assetUrls"; | |
| 139 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; | |
| 130 | 140 | import { cn } from "../lib/utils"; |
| 131 | 141 | import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; |
| 132 | 142 | import { useRightPanelStore } from "../rightPanelStore"; |
| ... | ... | @@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: { |
| 195 | 205 | canOpenInEditor: boolean; |
| 196 | 206 | canOpenInBrowser: boolean; |
| 197 | 207 | canOpenInPanel: boolean; |
| 208 | canOpenMedia?: boolean; | |
| 198 | 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 | } |
| 201 | 217 | |
| 202 | 218 | export function shouldUseMarkdownFileBrowserPrimaryAction(input: { |
| ... | ... | @@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps { |
| 1011 | 1027 | onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; |
| 1012 | 1028 | openInEditorMenuLabel: string; |
| 1013 | 1029 | onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; |
| 1030 | onOpenMedia?: (() => void) | undefined; | |
| 1014 | 1031 | onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; |
| 1015 | 1032 | /** Platform-specific menu label ("Reveal in Finder", ...); required for the |
| 1016 | 1033 | reveal item to show. */ |
| ... | ... | @@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: |
| 1133 | 1150 | ); |
| 1134 | 1151 | }); |
| 1135 | 1152 | |
| 1136 | const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; | |
| 1153 | const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; | |
| 1154 | const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( | |
| 1155 | "max-h-[30rem]", | |
| 1156 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, | |
| 1157 | ); | |
| 1158 | const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; | |
| 1159 | const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; | |
| 1137 | 1160 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( |
| 1138 | 1161 | "h-auto w-auto object-contain", |
| 1139 | CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, | |
| 1162 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, | |
| 1140 | 1163 | ); |
| 1141 | 1164 | |
| 1142 | 1165 | function markdownImageCopy(alt: string, src: string, title: string | undefined): string { |
| ... | ... | @@ -1167,11 +1190,10 @@ function authoredImageSizeStyle( |
| 1167 | 1190 | return undefined; |
| 1168 | 1191 | } |
| 1169 | 1192 | |
| 1170 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; | |
| 1171 | 1193 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1172 | 1194 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| 1173 | CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, | |
| 1174 | "rounded-lg border border-border/40", | |
| 1195 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | |
| 1196 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, | |
| 1175 | 1197 | ); |
| 1176 | 1198 | const MarkdownLinkContext = React.createContext(false); |
| 1177 | 1199 | |
| ... | ... | @@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps( |
| 1179 | 1201 | onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, |
| 1180 | 1202 | src: string, |
| 1181 | 1203 | alt: string, |
| 1204 | originalUrl?: string, | |
| 1205 | actionsSource?: MediaActionSource, | |
| 1182 | 1206 | ) { |
| 1183 | 1207 | if (!onImageExpand) return {}; |
| 1184 | 1208 | const previewName = alt.trim() || "image"; |
| ... | ... | @@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps( |
| 1186 | 1210 | if (event.currentTarget.closest("a")) return; |
| 1187 | 1211 | event.preventDefault(); |
| 1188 | 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 | 1225 | return { |
| 1192 | 1226 | role: "button" as const, |
| ... | ... | @@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps( |
| 1202 | 1236 | function ChatMarkdownImageFallback(props: { |
| 1203 | 1237 | readonly alt: string; |
| 1204 | 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 | 1244 | <span |
| 1208 | 1245 | data-markdown-copy={props.copyMarkdown} |
| 1209 | 1246 | className={cn( |
| 1210 | CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, | |
| 1247 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | |
| 1211 | 1248 | "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground", |
| 1212 | 1249 | )} |
| 1213 | 1250 | > |
| 1214 | 1251 | <span className="inline-flex items-center gap-1.5"> |
| 1215 | 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 | 1254 | </span> |
| 1218 | 1255 | </span> |
| 1219 | 1256 | ); |
| 1257 | return props.actionsSource ? ( | |
| 1258 | <MediaActions source={props.actionsSource}>{content}</MediaActions> | |
| 1259 | ) : ( | |
| 1260 | content | |
| 1261 | ); | |
| 1262 | } | |
| 1263 | ||
| 1264 | function 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 | } |
| 1221 | 1320 | |
| 1222 | /** Environment-hosted images load through a signed asset URL. */ | |
| 1321 | /** Environment-hosted media loads through an exact-file signed asset URL. */ | |
| 1223 | 1322 | export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { |
| 1224 | 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 | 1329 | readonly alt: string; |
| 1227 | 1330 | readonly copyMarkdown?: string; |
| 1228 | 1331 | readonly srcFragment?: string; |
| 1229 | 1332 | readonly style?: CSSProperties | undefined; |
| 1333 | readonly workspaceRoot?: string | undefined; | |
| 1230 | 1334 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1231 | 1335 | }) { |
| 1232 | 1336 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| 1337 | const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); | |
| 1233 | 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 | } | |
| 1234 | 1383 | |
| 1235 | 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 | 1394 | if (assetUrl._tag !== "Success") { |
| 1239 | 1395 | return ( |
| 1240 | <span | |
| 1396 | <MediaActions source={actionsSource}> | |
| 1397 | <span | |
| 1398 | data-markdown-copy={props.copyMarkdown} | |
| 1399 | role="status" | |
| 1400 | aria-label="Loading image" | |
| 1401 | className={cn( | |
| 1402 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | |
| 1403 | "aspect-video w-64 max-w-full rounded-lg bg-muted/60", | |
| 1404 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, | |
| 1405 | )} | |
| 1406 | style={props.style} | |
| 1407 | /> | |
| 1408 | </MediaActions> | |
| 1409 | ); | |
| 1410 | } | |
| 1411 | return ( | |
| 1412 | <MediaActions source={actionsSource}> | |
| 1413 | <img | |
| 1414 | src={src ?? undefined} | |
| 1415 | alt={props.alt} | |
| 1241 | 1416 | data-markdown-copy={props.copyMarkdown} |
| 1242 | role="status" | |
| 1243 | aria-label="Loading image" | |
| 1417 | loading="lazy" | |
| 1418 | draggable={false} | |
| 1244 | 1419 | className={cn( |
| 1245 | CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, | |
| 1246 | "aspect-video w-64 max-w-full rounded-lg bg-muted/60", | |
| 1247 | CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, | |
| 1420 | CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME, | |
| 1421 | props.onImageExpand && "cursor-zoom-in", | |
| 1248 | 1422 | )} |
| 1249 | 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 | ); | |
| 1252 | } | |
| 1253 | const src = assetUrl.url + (props.srcFragment ?? ""); | |
| 1254 | return ( | |
| 1255 | <img | |
| 1256 | src={src} | |
| 1257 | alt={props.alt} | |
| 1258 | data-markdown-copy={props.copyMarkdown} | |
| 1259 | loading="lazy" | |
| 1260 | draggable={false} | |
| 1261 | className={cn( | |
| 1262 | CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME, | |
| 1263 | props.onImageExpand && "cursor-zoom-in", | |
| 1264 | )} | |
| 1265 | style={props.style} | |
| 1266 | {...expandableMarkdownImageProps(props.onImageExpand, src, props.alt)} | |
| 1267 | onError={() => setFailedUrl(assetUrl.url)} | |
| 1268 | /> | |
| 1433 | </MediaActions> | |
| 1269 | 1434 | ); |
| 1270 | 1435 | }); |
| 1271 | 1436 | |
| ... | ... | @@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1448 | 1613 | onOpenInPanel, |
| 1449 | 1614 | openInEditorMenuLabel, |
| 1450 | 1615 | onOpenInBrowser, |
| 1616 | onOpenMedia, | |
| 1451 | 1617 | onReveal, |
| 1452 | 1618 | revealLabel, |
| 1453 | 1619 | className, |
| ... | ... | @@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1491 | 1657 | }, [onOpen, targetPath]); |
| 1492 | 1658 | |
| 1493 | 1659 | const handleOpenInFilePreview = useCallback(() => { |
| 1494 | if (!threadRef || !workspaceRelativePath) { | |
| 1495 | handleOpenInEditor(); | |
| 1660 | if (threadRef && workspaceRelativePath) { | |
| 1661 | onOpenInPanel(workspaceRelativePath, line); | |
| 1496 | 1662 | return; |
| 1497 | 1663 | } |
| 1498 | onOpenInPanel(workspaceRelativePath, line); | |
| 1499 | }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); | |
| 1664 | if (onOpenMedia) { | |
| 1665 | onOpenMedia(); | |
| 1666 | return; | |
| 1667 | } | |
| 1668 | handleOpenInEditor(); | |
| 1669 | }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); | |
| 1500 | 1670 | |
| 1501 | 1671 | const handleOpenInBrowser = useCallback(() => { |
| 1502 | 1672 | if (!onOpenInBrowser) { |
| ... | ... | @@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1621 | 1791 | try { |
| 1622 | 1792 | const clicked = await api.contextMenu.show( |
| 1623 | 1793 | [ |
| 1794 | ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), | |
| 1624 | 1795 | ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), |
| 1625 | 1796 | ...(onOpenInBrowser |
| 1626 | 1797 | ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) |
| ... | ... | @@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1632 | 1803 | position, |
| 1633 | 1804 | ); |
| 1634 | 1805 | |
| 1806 | if (clicked === "preview-media") { | |
| 1807 | onOpenMedia?.(); | |
| 1808 | return; | |
| 1809 | } | |
| 1635 | 1810 | if (clicked === "open") { |
| 1636 | 1811 | handleOpenInEditor(); |
| 1637 | 1812 | return; |
| ... | ... | @@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1665 | 1840 | handleOpenInEditor, |
| 1666 | 1841 | handleRevealInFileManager, |
| 1667 | 1842 | onOpenInBrowser, |
| 1843 | onOpenMedia, | |
| 1668 | 1844 | onOpen, |
| 1669 | 1845 | onReveal, |
| 1670 | 1846 | openInEditorMenuLabel, |
| ... | ... | @@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1696 | 1872 | canOpenInEditor, |
| 1697 | 1873 | canOpenInBrowser, |
| 1698 | 1874 | canOpenInPanel, |
| 1875 | canOpenMedia: onOpenMedia !== undefined, | |
| 1699 | 1876 | }); |
| 1700 | 1877 | const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ |
| 1701 | 1878 | iconPath, |
| ... | ... | @@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1787 | 1964 | previous.onOpenInPanel === next.onOpenInPanel && |
| 1788 | 1965 | previous.openInEditorMenuLabel === next.openInEditorMenuLabel && |
| 1789 | 1966 | previous.onOpenInBrowser === next.onOpenInBrowser && |
| 1967 | previous.onOpenMedia === next.onOpenMedia && | |
| 1790 | 1968 | previous.onReveal === next.onReveal && |
| 1791 | 1969 | previous.revealLabel === next.revealLabel && |
| 1792 | 1970 | previous.className === next.className |
| ... | ... | @@ -1810,8 +1988,18 @@ function ChatMarkdown({ |
| 1810 | 1988 | extraRemarkPlugins = EMPTY_REMARK_PLUGINS, |
| 1811 | 1989 | }: ChatMarkdownProps) { |
| 1812 | 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 | 2000 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1814 | 2001 | reportFailure: false, |
| 2002 | refresh: true, | |
| 1815 | 2003 | }); |
| 1816 | 2004 | const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { |
| 1817 | 2005 | reportFailure: false, |
| ... | ... | @@ -1830,6 +2018,41 @@ function ChatMarkdown({ |
| 1830 | 2018 | remoteOpen.isResolved, |
| 1831 | 2019 | ); |
| 1832 | 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 | 2056 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 1834 | 2057 | const threadServerConfig = useAtomValue( |
| 1835 | 2058 | serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), |
| ... | ... | @@ -2068,6 +2291,7 @@ function ChatMarkdown({ |
| 2068 | 2291 | fileLinkMeta: MarkdownFileLinkMeta, |
| 2069 | 2292 | copyMarkdown: string, |
| 2070 | 2293 | className?: string, |
| 2294 | mediaSource?: string, | |
| 2071 | 2295 | ) => { |
| 2072 | 2296 | const parentSuffix = fileLinkParentSuffixByPath.get( |
| 2073 | 2297 | fileLinkMeta.filePath.replaceAll("\\", "/"), |
| ... | ... | @@ -2081,6 +2305,11 @@ function ChatMarkdown({ |
| 2081 | 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; | |
| 2084 | 2313 | |
| 2085 | 2314 | return ( |
| 2086 | 2315 | <MarkdownFileLink |
| ... | ... | @@ -2096,6 +2325,11 @@ function ChatMarkdown({ |
| 2096 | 2325 | threadRef={threadRef} |
| 2097 | 2326 | {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} |
| 2098 | 2327 | onOpenInPanel={openFileInPanel} |
| 2328 | onOpenMedia={ | |
| 2329 | threadRef && canPreviewMedia | |
| 2330 | ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) | |
| 2331 | : undefined | |
| 2332 | } | |
| 2099 | 2333 | openInEditorMenuLabel={preferredEditorMenuLabel} |
| 2100 | 2334 | onReveal={ |
| 2101 | 2335 | canUseShellActions && revealInFileManagerLabel !== undefined |
| ... | ... | @@ -2232,6 +2466,21 @@ function ChatMarkdown({ |
| 2232 | 2466 | handleMarkdownFragmentClick(event, href); |
| 2233 | 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 | 2484 | // A link to a change request in a workspace project opens beside the |
| 2236 | 2485 | // conversation instead of in a browser: it is the thing being talked about, and |
| 2237 | 2486 | // the panel it opens offers the browser as one of its actions. Anything else is |
| ... | ... | @@ -2323,6 +2572,7 @@ function ChatMarkdown({ |
| 2323 | 2572 | fileLinkMeta, |
| 2324 | 2573 | `[${fileLinkMeta.basename}](${normalizedHref})`, |
| 2325 | 2574 | props.className, |
| 2575 | normalizedHref, | |
| 2326 | 2576 | ); |
| 2327 | 2577 | }, |
| 2328 | 2578 | code({ node, children, className, ...props }) { |
| ... | ... | @@ -2332,7 +2582,12 @@ function ChatMarkdown({ |
| 2332 | 2582 | inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? |
| 2333 | 2583 | resolveInlineCodeFileLinkMeta(codeText, cwd); |
| 2334 | 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 | 2593 | return ( |
| ... | ... | @@ -2342,7 +2597,7 @@ function ChatMarkdown({ |
| 2342 | 2597 | ); |
| 2343 | 2598 | }, |
| 2344 | 2599 | img: function MarkdownImage({ node, title, src, alt, ...props }) { |
| 2345 | const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; | |
| 2600 | const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; | |
| 2346 | 2601 | const localSrc = node?.properties?.dataLocalSrc; |
| 2347 | 2602 | const markdownTitle = node?.properties?.dataMarkdownTitle; |
| 2348 | 2603 | const authoredSrc = typeof localSrc === "string" ? localSrc : src; |
| ... | ... | @@ -2355,21 +2610,53 @@ function ChatMarkdown({ |
| 2355 | 2610 | const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); |
| 2356 | 2611 | const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); |
| 2357 | 2612 | const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); |
| 2613 | const kind = mediaKindFromPath(classifiedSrc) ?? "image"; | |
| 2358 | 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 | 2638 | return ( |
| 2360 | <img | |
| 2361 | {...props} | |
| 2362 | src={imageSource.uri} | |
| 2363 | alt={altText} | |
| 2364 | loading="lazy" | |
| 2365 | className={cn( | |
| 2366 | props.className, | |
| 2367 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, | |
| 2368 | imageExpand && "cursor-zoom-in", | |
| 2369 | )} | |
| 2370 | style={authoredSizeStyle} | |
| 2371 | {...expandableMarkdownImageProps(imageExpand, imageSource.uri, altText)} | |
| 2372 | /> | |
| 2639 | <MediaActions source={actionsSource}> | |
| 2640 | <img | |
| 2641 | {...props} | |
| 2642 | src={mediaSrc} | |
| 2643 | alt={altText} | |
| 2644 | loading="lazy" | |
| 2645 | className={cn( | |
| 2646 | props.className, | |
| 2647 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, | |
| 2648 | imageExpand && "cursor-zoom-in", | |
| 2649 | )} | |
| 2650 | style={authoredSizeStyle} | |
| 2651 | {...expandableMarkdownImageProps( | |
| 2652 | imageExpand, | |
| 2653 | mediaSrc, | |
| 2654 | altText, | |
| 2655 | originalUrl, | |
| 2656 | actionsSource, | |
| 2657 | )} | |
| 2658 | /> | |
| 2659 | </MediaActions> | |
| 2373 | 2660 | ); |
| 2374 | 2661 | } |
| 2375 | 2662 | if (imageSource._tag === "WorkspaceFile" && threadRef) { |
| ... | ... | @@ -2377,19 +2664,21 @@ function ChatMarkdown({ |
| 2377 | 2664 | <ChatMarkdownAssetImage |
| 2378 | 2665 | environmentId={threadRef.environmentId} |
| 2379 | 2666 | resource={{ |
| 2380 | _tag: "workspace-file", | |
| 2667 | _tag: "media-file", | |
| 2381 | 2668 | threadId: threadRef.threadId, |
| 2382 | 2669 | path: imageSource.path, |
| 2383 | 2670 | }} |
| 2384 | 2671 | alt={altText} |
| 2672 | kind={kind} | |
| 2385 | 2673 | copyMarkdown={copyMarkdown} |
| 2386 | 2674 | srcFragment={markdownImageSourceFragment(classifiedSrc)} |
| 2387 | 2675 | style={authoredSizeStyle} |
| 2676 | workspaceRoot={cwd} | |
| 2388 | 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 | 2683 | table({ node: _node, ...props }) { |
| 2395 | 2684 | return <MarkdownTable {...props} />; |
| ... | ... | @@ -2438,6 +2727,8 @@ function ChatMarkdown({ |
| 2438 | 2727 | onTaskListChange, |
| 2439 | 2728 | onUseArtifactTemplate, |
| 2440 | 2729 | onImageExpand, |
| 2730 | expandMedia, | |
| 2731 | openMarkdownMedia, | |
| 2441 | 2732 | openFileInPanel, |
| 2442 | 2733 | openInPreferredEditor, |
| 2443 | 2734 | openChangeRequestLink, |
| ... | ... | @@ -2483,6 +2774,12 @@ function ChatMarkdown({ |
| 2483 | 2774 | > |
| 2484 | 2775 | {text} |
| 2485 | 2776 | </ReactMarkdown> |
| 2777 | {localMediaPreview ? ( | |
| 2778 | <ExpandedImageDialog | |
| 2779 | preview={localMediaPreview} | |
| 2780 | onClose={() => setLocalMediaPreview(null)} | |
| 2781 | /> | |
| 2782 | ) : null} | |
| 2486 | 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 | 9 | |
| 10 | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 11 | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | useAssetUrlRefresh: () => vi.fn(), | |
| 12 | 13 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 13 | 14 | testState.resources.push(resource); |
| 14 | 15 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| ... | ... | @@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => { |
| 106 | 107 | |
| 107 | 108 | expect(testState.resources).toEqual([ |
| 108 | 109 | { |
| 109 | _tag: "workspace-file", | |
| 110 | _tag: "media-file", | |
| 110 | 111 | threadId: threadRef.threadId, |
| 111 | 112 | path: expectedPath, |
| 112 | 113 | }, |
| ... | ... | @@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => { |
| 126 | 127 | |
| 127 | 128 | expect(testState.resources).toEqual([ |
| 128 | 129 | { |
| 129 | _tag: "workspace-file", | |
| 130 | _tag: "media-file", | |
| 130 | 131 | threadId: threadRef.threadId, |
| 131 | 132 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", |
| 132 | 133 | }, |
| 133 | { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, | |
| 134 | { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, | |
| 134 | { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 135 | { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 135 | 136 | { |
| 136 | _tag: "workspace-file", | |
| 137 | _tag: "media-file", | |
| 137 | 138 | threadId: threadRef.threadId, |
| 138 | 139 | path: "\\\\server\\share\\workspace-image.svg", |
| 139 | 140 | }, |
| ... | ... | @@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => { |
| 149 | 150 | |
| 150 | 151 | expect(testState.resources).toEqual([ |
| 151 | 152 | { |
| 152 | _tag: "workspace-file", | |
| 153 | _tag: "media-file", | |
| 153 | 154 | threadId: threadRef.threadId, |
| 154 | 155 | path: "D:/screens/workspace-image.svg", |
| 155 | 156 | }, |
apps/web/src/components/ChatView.logic.test.ts-18| ... | ... | @@ -22,7 +22,6 @@ import { |
| 22 | 22 | dismissBranchMismatchForSession, |
| 23 | 23 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, |
| 24 | 24 | getStartedThreadModelChangeBlockReason, |
| 25 | loadVideoPreviewUrl, | |
| 26 | 25 | isVideoPreviewRequestCurrent, |
| 27 | 26 | hasEnvironmentReconnectWarningGraceElapsed, |
| 28 | 27 | hasServerAcknowledgedLocalDispatch, |
| ... | ... | @@ -44,23 +43,6 @@ import { |
| 44 | 43 | shouldWriteThreadErrorToCurrentServerThread, |
| 45 | 44 | } from "./ChatView.logic"; |
| 46 | 45 | |
| 47 | describe("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 | ||
| 64 | 46 | describe("isVideoPreviewRequestCurrent", () => { |
| 65 | 47 | it("rejects changed threads and replaced previews", () => { |
| 66 | 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 | 1 | import { |
| 2 | type AssetCreateUrlInput, | |
| 3 | type AssetCreateUrlResult, | |
| 4 | type ChatFileAttachment, | |
| 2 | 5 | type EnvironmentId, |
| 3 | 6 | isProviderDriverKind, |
| 4 | 7 | ProjectId, |
| ... | ... | @@ -12,6 +15,12 @@ import { |
| 12 | 15 | type ThreadId, |
| 13 | 16 | type TurnId, |
| 14 | 17 | } from "@t3tools/contracts"; |
| 18 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 19 | import { | |
| 20 | squashAtomCommandFailure, | |
| 21 | type AtomCommandResult, | |
| 22 | } from "@t3tools/client-runtime/state/runtime"; | |
| 23 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 15 | 24 | import { |
| 16 | 25 | appendCodexArtifactTemplateUsePrompt, |
| 17 | 26 | codexArtifactTemplateUsePrompt, |
| ... | ... | @@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { |
| 299 | 308 | URL.revokeObjectURL(previewUrl); |
| 300 | 309 | } |
| 301 | 310 | |
| 302 | export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise<string> { | |
| 303 | const response = await fetch(url, signal ? { signal } : {}); | |
| 304 | if (!response.ok) throw new Error(`Could not load video (${response.status}).`); | |
| 305 | return URL.createObjectURL(await response.blob()); | |
| 311 | /** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ | |
| 312 | export async function resolveFileAttachmentUrl(input: { | |
| 313 | attachment: ChatFileAttachment; | |
| 314 | environmentId: EnvironmentId; | |
| 315 | httpBaseUrl: string; | |
| 316 | createAssetUrl: (input: { | |
| 317 | environmentId: EnvironmentId; | |
| 318 | input: AssetCreateUrlInput; | |
| 319 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>; | |
| 320 | }): Promise<string> { | |
| 321 | const { attachment } = input; | |
| 322 | const result = await input.createAssetUrl({ | |
| 323 | environmentId: input.environmentId, | |
| 324 | input: { | |
| 325 | resource: { | |
| 326 | _tag: "attachment", | |
| 327 | attachmentId: attachment.id, | |
| 328 | fileName: attachment.name, | |
| 329 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, | |
| 330 | }, | |
| 331 | }, | |
| 332 | }); | |
| 333 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 334 | const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); | |
| 335 | if (url === null) throw new Error("The environment returned an invalid attachment URL."); | |
| 336 | return url; | |
| 306 | 337 | } |
| 307 | 338 | |
| 308 | 339 | export function isVideoPreviewRequestCurrent( |
apps/web/src/components/ChatView.tsx+27-73| ... | ... | @@ -365,7 +365,7 @@ import { |
| 365 | 365 | cloneComposerImageForRetry, |
| 366 | 366 | deriveLockedProvider, |
| 367 | 367 | readFileAsDataUrl, |
| 368 | loadVideoPreviewUrl, | |
| 368 | resolveFileAttachmentUrl, | |
| 369 | 369 | isVideoPreviewRequestCurrent, |
| 370 | 370 | reconcileMountedTerminalThreadIds, |
| 371 | 371 | resolveBackgroundDraftWorkspaceOptions, |
| ... | ... | @@ -424,7 +424,7 @@ import { |
| 424 | 424 | resolveServerSelfUpdateCapability, |
| 425 | 425 | serverUpdateGuidance, |
| 426 | 426 | } from "../versionSkew"; |
| 427 | import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; | |
| 427 | import { useAssetUrls } from "../assets/assetUrls"; | |
| 428 | 428 | |
| 429 | 429 | const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = |
| 430 | 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 | 1331 | const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 1332 | 1332 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1333 | 1333 | reportFailure: false, |
| 1334 | refresh: true, | |
| 1334 | 1335 | }); |
| 1335 | 1336 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { |
| 1336 | 1337 | reportFailure: false, |
| ... | ... | @@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1459 | 1460 | const routeThreadKeyRef = useRef(routeThreadKey); |
| 1460 | 1461 | routeThreadKeyRef.current = routeThreadKey; |
| 1461 | 1462 | const videoPreviewRequestIdRef = useRef(0); |
| 1462 | const videoPreviewAbortControllerRef = useRef<AbortController | null>(null); | |
| 1463 | 1463 | const cancelVideoPreviewRequest = useCallback(() => { |
| 1464 | 1464 | videoPreviewRequestIdRef.current += 1; |
| 1465 | videoPreviewAbortControllerRef.current?.abort(); | |
| 1466 | videoPreviewAbortControllerRef.current = null; | |
| 1467 | 1465 | }, []); |
| 1468 | 1466 | const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null); |
| 1469 | 1467 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| ... | ... | @@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 2542 | 2540 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2543 | 2541 | return; |
| 2544 | 2542 | } |
| 2545 | const videoMime = videoMimeType(attachment); | |
| 2546 | const isVideo = videoMime !== null; | |
| 2543 | const isVideo = videoMimeType(attachment) !== null; | |
| 2547 | 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 | 2545 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; |
| 2554 | 2546 | const isCurrentRequest = () => |
| 2555 | 2547 | !isVideo || |
| ... | ... | @@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) { |
| 2559 | 2551 | videoPreviewRequestId, |
| 2560 | 2552 | videoPreviewRequestIdRef.current, |
| 2561 | 2553 | ); |
| 2562 | const finishVideoPreviewRequest = () => { | |
| 2563 | if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | |
| 2564 | setOpeningVideoAttachmentId(null); | |
| 2565 | videoPreviewAbortControllerRef.current = null; | |
| 2566 | } | |
| 2567 | }; | |
| 2568 | 2554 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); |
| 2569 | 2555 | |
| 2570 | // fileName and mimeType ride in the signed claims so videos render | |
| 2571 | // inline while other files keep their real download name and type. | |
| 2572 | const result = await createAttachmentAssetUrl({ | |
| 2573 | environmentId, | |
| 2574 | input: { | |
| 2575 | resource: { | |
| 2576 | _tag: "attachment", | |
| 2577 | attachmentId: attachment.id, | |
| 2578 | fileName: attachment.name, | |
| 2579 | mimeType: videoMime ?? attachment.mimeType, | |
| 2580 | }, | |
| 2581 | }, | |
| 2582 | }); | |
| 2583 | if (!isCurrentRequest()) { | |
| 2584 | finishVideoPreviewRequest(); | |
| 2585 | return; | |
| 2586 | } | |
| 2587 | if (result._tag === "Failure") { | |
| 2588 | finishVideoPreviewRequest(); | |
| 2589 | const error = squashAtomCommandFailure(result); | |
| 2590 | toastManager.add({ | |
| 2591 | type: "error", | |
| 2592 | title: "Could not " + action + " " + attachment.name, | |
| 2593 | description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2556 | try { | |
| 2557 | const url = await resolveFileAttachmentUrl({ | |
| 2558 | attachment, | |
| 2559 | environmentId, | |
| 2560 | httpBaseUrl: connection.httpBaseUrl, | |
| 2561 | createAssetUrl: createAttachmentAssetUrl, | |
| 2594 | 2562 | }); |
| 2595 | return; | |
| 2596 | } | |
| 2597 | ||
| 2598 | const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); | |
| 2599 | if (!url) { | |
| 2600 | finishVideoPreviewRequest(); | |
| 2601 | toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); | |
| 2602 | return; | |
| 2603 | } | |
| 2604 | if (isVideo) { | |
| 2605 | try { | |
| 2606 | const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); | |
| 2607 | if (!isCurrentRequest()) { | |
| 2608 | revokeBlobPreviewUrl(previewUrl); | |
| 2609 | return; | |
| 2610 | } | |
| 2563 | if (!isCurrentRequest()) return; | |
| 2564 | if (isVideo) { | |
| 2611 | 2565 | setExpandedImage({ |
| 2612 | images: [{ src: previewUrl, name: attachment.name, type: "video" }], | |
| 2566 | images: [{ src: url, name: attachment.name, type: "video" }], | |
| 2613 | 2567 | index: 0, |
| 2614 | 2568 | }); |
| 2615 | } catch (error) { | |
| 2616 | if (!isCurrentRequest()) return; | |
| 2617 | toastManager.add({ | |
| 2618 | type: "error", | |
| 2619 | title: "Could not play " + attachment.name, | |
| 2620 | description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2621 | }); | |
| 2622 | } finally { | |
| 2623 | finishVideoPreviewRequest(); | |
| 2569 | return; | |
| 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 | 2586 | [createAttachmentAssetUrl, environmentId, routeThreadKey], |
| 2633 | 2587 | ); |
apps/web/src/components/chat/ChatComposer.tsx+2-3| ... | ... | @@ -172,6 +172,7 @@ import { |
| 172 | 172 | submitComposerDraft, |
| 173 | 173 | } from "./composerSubmission"; |
| 174 | 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 175 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 175 | 176 | |
| 176 | 177 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 177 | 178 | const setVideo = useCallback( |
| ... | ... | @@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) { |
| 189 | 190 | playsInline |
| 190 | 191 | preload="metadata" |
| 191 | 192 | aria-hidden="true" |
| 192 | onLoadedMetadata={(event) => { | |
| 193 | event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0); | |
| 194 | }} | |
| 193 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} | |
| 195 | 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 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { ExpandedImageDialog } from "./ExpandedImageDialog"; | |
| 5 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 6 | ||
| 7 | describe("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 | import { memo, useCallback, useEffect, useState } from "react"; | |
| 2 | import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react"; | |
| 1 | import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; | |
| 2 | import { createPortal } from "react-dom"; | |
| 3 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; | |
| 3 | 4 | import { Button } from "../ui/button"; |
| 4 | import { downloadVideoPreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 5 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 6 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 7 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; | |
| 8 | import { OpenMediaLink } from "../media/OpenMediaLink"; | |
| 9 | import { MediaActions, type MediaActionSource } from "../media/MediaActions"; | |
| 10 | import { isContextMenuOpen } from "../../contextMenuFallback"; | |
| 5 | 11 | |
| 6 | 12 | interface ExpandedImageDialogProps { |
| 7 | 13 | preview: ExpandedImagePreview; |
| 8 | 14 | onClose: () => void; |
| 9 | 15 | } |
| 10 | 16 | |
| 17 | function 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 | ||
| 11 | 28 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 12 | 29 | preview, |
| 13 | 30 | onClose, |
| 14 | 31 | }: ExpandedImageDialogProps) { |
| 15 | 32 | const [imageOffset, setImageOffset] = useState(0); |
| 16 | 33 | const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null); |
| 17 | const [downloadingVideoSrc, setDownloadingVideoSrc] = useState<string | null>(null); | |
| 18 | const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState<string | null>(null); | |
| 34 | const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); | |
| 19 | 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; | |
| 20 | 52 | |
| 21 | 53 | const navigateImage = useCallback((direction: -1 | 1) => { |
| 22 | 54 | setImageOffset((current) => current + direction); |
| 23 | 55 | }, []); |
| 24 | 56 | |
| 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 | 57 | useEffect(() => { |
| 38 | 58 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 59 | if (event.defaultPrevented || isContextMenuOpen()) { | |
| 60 | return; | |
| 61 | } | |
| 39 | 62 | if (event.key === "Escape") { |
| 40 | 63 | event.preventDefault(); |
| 41 | 64 | event.stopPropagation(); |
| ... | ... | @@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 58 | 81 | return () => window.removeEventListener("keydown", onKeyDown); |
| 59 | 82 | }, [navigateImage, onClose, preview.images.length]); |
| 60 | 83 | |
| 61 | const item = preview.images[index]; | |
| 62 | 84 | if (!item) return null; |
| 63 | 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; | |
| 64 | 90 | |
| 65 | const isDownloadingVideo = downloadingVideoSrc === item.src; | |
| 66 | const videoDownloadFailed = downloadFailedVideoSrc === item.src; | |
| 67 | return ( | |
| 91 | return createPortal( | |
| 68 | 92 | <div |
| 69 | 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 | 94 | role="dialog" |
| ... | ... | @@ -89,61 +113,59 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 89 | 113 | <ChevronLeftIcon className="size-5" /> |
| 90 | 114 | </Button> |
| 91 | 115 | )} |
| 92 | <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]"> | |
| 93 | <Button | |
| 94 | type="button" | |
| 95 | size="icon-xs" | |
| 96 | variant="ghost" | |
| 97 | className="absolute right-2 top-2" | |
| 98 | onClick={onClose} | |
| 99 | aria-label={`Close ${mediaLabel} preview`} | |
| 100 | > | |
| 101 | <XIcon /> | |
| 102 | </Button> | |
| 103 | {item.type === "video" && failedVideoSrc === item.src ? ( | |
| 104 | <div className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-white shadow-2xl"> | |
| 105 | <p className="text-sm"> | |
| 106 | {videoDownloadFailed | |
| 107 | ? "Could not download this video." | |
| 108 | : "This video format cannot be played here."} | |
| 109 | </p> | |
| 110 | <Button | |
| 111 | size="sm" | |
| 112 | variant="secondary" | |
| 113 | aria-busy={isDownloadingVideo || undefined} | |
| 114 | aria-disabled={isDownloadingVideo || undefined} | |
| 115 | onClick={() => { | |
| 116 | if (isDownloadingVideo) return; | |
| 117 | void downloadVideo(item.src, item.name); | |
| 118 | }} | |
| 119 | > | |
| 120 | <DownloadIcon /> | |
| 121 | {isDownloadingVideo ? "Downloading…" : "Download video"} | |
| 122 | </Button> | |
| 123 | </div> | |
| 124 | ) : item.type === "video" ? ( | |
| 125 | <video | |
| 126 | src={item.src} | |
| 127 | aria-label={item.name} | |
| 128 | autoPlay | |
| 129 | controls | |
| 130 | playsInline | |
| 131 | onError={() => setFailedVideoSrc(item.src)} | |
| 132 | className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl" | |
| 133 | /> | |
| 134 | ) : ( | |
| 135 | <img | |
| 136 | src={item.src} | |
| 137 | alt={item.name} | |
| 138 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 139 | draggable={false} | |
| 140 | /> | |
| 141 | )} | |
| 142 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | |
| 143 | {item.name} | |
| 144 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} | |
| 145 | </p> | |
| 146 | </div> | |
| 116 | <MediaActions source={actionsSource}> | |
| 117 | <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]"> | |
| 118 | <Button | |
| 119 | type="button" | |
| 120 | size="icon-xs" | |
| 121 | variant="ghost" | |
| 122 | className="absolute right-2 top-2 z-20" | |
| 123 | onClick={onClose} | |
| 124 | aria-label={`Close ${mediaLabel} preview`} | |
| 125 | > | |
| 126 | <XIcon /> | |
| 127 | </Button> | |
| 128 | {item.type === "video" && failedVideoSrc === item.src ? ( | |
| 129 | <ExpandedMediaFailure> | |
| 130 | <p>This video could not be loaded or played.</p> | |
| 131 | <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} /> | |
| 132 | </ExpandedMediaFailure> | |
| 133 | ) : item.type === "video" ? ( | |
| 134 | <video | |
| 135 | src={item.src} | |
| 136 | aria-label={item.name} | |
| 137 | autoPlay={item.autoPlay ?? true} | |
| 138 | preload="metadata" | |
| 139 | controls | |
| 140 | playsInline | |
| 141 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} | |
| 142 | onError={() => setFailedVideoSrc(item.src)} | |
| 143 | className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl" | |
| 144 | /> | |
| 145 | ) : failedImageSrc === item.src ? ( | |
| 146 | <ExpandedMediaFailure> | |
| 147 | <p> | |
| 148 | {openOriginalLink | |
| 149 | ? "This image could not be loaded." | |
| 150 | : "Image unavailable. The file may have been moved or deleted."} | |
| 151 | </p> | |
| 152 | {openOriginalLink} | |
| 153 | </ExpandedMediaFailure> | |
| 154 | ) : ( | |
| 155 | <img | |
| 156 | src={item.src} | |
| 157 | alt={item.name} | |
| 158 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 159 | draggable={false} | |
| 160 | onError={() => setFailedImageSrc(item.src)} | |
| 161 | /> | |
| 162 | )} | |
| 163 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | |
| 164 | {item.name} | |
| 165 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} | |
| 166 | </p> | |
| 167 | </div> | |
| 168 | </MediaActions> | |
| 147 | 169 | {preview.images.length > 1 && ( |
| 148 | 170 | <Button |
| 149 | 171 | type="button" |
| ... | ... | @@ -156,6 +178,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 156 | 178 | <ChevronRightIcon className="size-5" /> |
| 157 | 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 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | 2 | |
| 3 | 3 | import type { ComposerFileAttachment } from "../../composerDraftStore"; |
| 4 | 4 | import { |
| 5 | 5 | attachVideoThumbnail, |
| 6 | 6 | buildExpandedImagePreview, |
| 7 | downloadVideoPreview, | |
| 7 | resolveMarkdownMediaPreview, | |
| 8 | 8 | } from "./ExpandedImagePreview"; |
| 9 | 9 | |
| 10 | describe("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 | ||
| 10 | 44 | describe("buildExpandedImagePreview", () => { |
| 11 | 45 | it("builds a video preview for a local video attachment", () => { |
| 12 | 46 | const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); |
| ... | ... | @@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => { |
| 40 | 74 | detach(); |
| 41 | 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 | 1 | import type { ComposerFileAttachment } from "../../composerDraftStore"; |
| 2 | 2 | import { type ChatImageAttachment, isVideoAttachment } from "../../types"; |
| 3 | import type { | |
| 4 | AssetCreateUrlResult, | |
| 5 | AssetResource, | |
| 6 | EnvironmentId, | |
| 7 | ScopedThreadRef, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | classifyMarkdownImageSource, | |
| 11 | markdownImageSourceFragment, | |
| 12 | } from "@t3tools/client-runtime/markdown-images"; | |
| 13 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | |
| 14 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 15 | import { | |
| 16 | squashAtomCommandFailure, | |
| 17 | type AtomCommandResult, | |
| 18 | } from "@t3tools/client-runtime/state/runtime"; | |
| 19 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | |
| 20 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; | |
| 21 | import type { MediaActionSource } from "../media/MediaActions"; | |
| 22 | import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; | |
| 3 | 23 | |
| 4 | 24 | export interface ExpandedImageItem { |
| 5 | 25 | src: string; |
| 6 | 26 | name: string; |
| 7 | 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 | } |
| 9 | 33 | |
| 10 | 34 | export interface ExpandedImagePreview { |
| ... | ... | @@ -12,23 +36,98 @@ export interface ExpandedImagePreview { |
| 12 | 36 | index: number; |
| 13 | 37 | } |
| 14 | 38 | |
| 39 | /** Resolves a chat media reference on its owning environment, without downloading its bytes. */ | |
| 40 | export 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 | ||
| 15 | 125 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { |
| 16 | 126 | const url = URL.createObjectURL(file); |
| 17 | 127 | video.src = url; |
| 18 | 128 | return () => URL.revokeObjectURL(url); |
| 19 | 129 | } |
| 20 | 130 | |
| 21 | export 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 | ||
| 32 | 131 | export function buildExpandedImagePreview( |
| 33 | 132 | images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>, |
| 34 | 133 | selectedImageId: string, |
apps/web/src/components/chat/MessagesTimeline.tsx+1| ... | ... | @@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2556 | 2556 | resource={viewedImage.resource} |
| 2557 | 2557 | alt={viewedImage.alt} |
| 2558 | 2558 | srcFragment={viewedImage.srcFragment} |
| 2559 | workspaceRoot={workspaceRoot} | |
| 2559 | 2560 | style={{ maxHeight: "16rem" }} |
| 2560 | 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 | 181 | it.each([ |
| 182 | 182 | ["https://example.com", "example.com"], |
| 183 | 183 | ["http://localhost:3000/path", "localhost"], |
| 184 | ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"], | |
| 185 | ["//", null], | |
| 184 | 186 | ["#details", null], |
| 185 | 187 | ["mailto:hello@example.com", null], |
| 186 | 188 | ["file:///tmp/example.txt", null], |
apps/web/src/components/chat/externalLinkContextMenu.ts+1-1| ... | ... | @@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions { |
| 76 | 76 | export function resolveExternalWebLinkHost(href: string | undefined): string | null { |
| 77 | 77 | if (!href) return null; |
| 78 | 78 | try { |
| 79 | const url = new URL(href); | |
| 79 | const url = new URL(href.startsWith("//") ? `https:${href}` : href); | |
| 80 | 80 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; |
| 81 | 81 | return url.hostname || null; |
| 82 | 82 | } catch { |
apps/web/src/components/files/FilePreviewPanel.tsx+115-23| ... | ... | @@ -4,7 +4,10 @@ import type { |
| 4 | 4 | ResolvedKeybindingsConfig, |
| 5 | 5 | ScopedThreadRef, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | |
| 7 | import { | |
| 8 | isWorkspaceImagePreviewPath, | |
| 9 | isWorkspaceVideoPreviewPath, | |
| 10 | } from "@t3tools/shared/filePreview"; | |
| 8 | 11 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; |
| 9 | 12 | import { Editor } from "@pierre/diffs/editor"; |
| 10 | 13 | import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; |
| ... | ... | @@ -12,13 +15,16 @@ import { |
| 12 | 15 | isAtomCommandInterrupted, |
| 13 | 16 | squashAtomCommandFailure, |
| 14 | 17 | } from "@t3tools/client-runtime/state/runtime"; |
| 18 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; | |
| 15 | 19 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; |
| 16 | 20 | import * as Schema from "effect/Schema"; |
| 17 | 21 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 18 | 22 | |
| 19 | 23 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 20 | import { useAssetUrlState } from "~/assets/assetUrls"; | |
| 24 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; | |
| 21 | 25 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 26 | import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; | |
| 27 | import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; | |
| 22 | 28 | import { useRemoteOpenState } from "~/remoteOpen"; |
| 23 | 29 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | 30 | import { useTheme } from "~/hooks/useTheme"; |
| ... | ... | @@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: { |
| 135 | 141 | readonly environmentId: EnvironmentId; |
| 136 | 142 | readonly threadRef: ScopedThreadRef; |
| 137 | 143 | readonly absolutePath: string; |
| 144 | readonly workspaceRoot: string; | |
| 138 | 145 | readonly alt: string; |
| 139 | 146 | readonly workspaceMutationId: string | null; |
| 140 | 147 | }) { |
| 141 | const assetUrl = useAssetUrlState(props.environmentId, { | |
| 142 | _tag: "workspace-file", | |
| 143 | threadId: props.threadRef.threadId, | |
| 144 | path: props.absolutePath, | |
| 145 | }); | |
| 148 | const resource = useMemo( | |
| 149 | () => ({ | |
| 150 | _tag: "workspace-file" as const, | |
| 151 | threadId: props.threadRef.threadId, | |
| 152 | path: props.absolutePath, | |
| 153 | }), | |
| 154 | [props.threadRef.threadId, props.absolutePath], | |
| 155 | ); | |
| 156 | const assetUrl = useAssetUrlState(props.environmentId, resource); | |
| 146 | 157 | const [failedUrl, setFailedUrl] = useState<string | null>(null); |
| 147 | 158 | const revisionSuffix = |
| 148 | 159 | props.workspaceMutationId === null |
| 149 | 160 | ? "" |
| 150 | 161 | : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; |
| 151 | 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 | }; | |
| 152 | 170 | |
| 153 | 171 | if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { |
| 154 | 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"> | |
| 156 | Unable to load workspace image. | |
| 157 | </div> | |
| 173 | <MediaActions source={actionsSource}> | |
| 174 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> | |
| 175 | Unable to load workspace image. | |
| 176 | </div> | |
| 177 | </MediaActions> | |
| 158 | 178 | ); |
| 159 | 179 | } |
| 160 | 180 | |
| 161 | 181 | return assetUrl._tag === "Success" && imageUrl !== null ? ( |
| 162 | 182 | <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> |
| 163 | <img | |
| 164 | className="max-h-full max-w-full object-contain" | |
| 165 | src={imageUrl} | |
| 166 | alt={props.alt} | |
| 167 | onError={() => setFailedUrl(imageUrl)} | |
| 168 | /> | |
| 183 | <MediaActions source={actionsSource}> | |
| 184 | <img | |
| 185 | className="max-h-full max-w-full object-contain" | |
| 186 | src={imageUrl} | |
| 187 | alt={props.alt} | |
| 188 | onError={() => setFailedUrl(imageUrl)} | |
| 189 | /> | |
| 190 | </MediaActions> | |
| 169 | 191 | </div> |
| 170 | 192 | ) : ( |
| 171 | 193 | <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> |
| ... | ... | @@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: { |
| 174 | 196 | ); |
| 175 | 197 | } |
| 176 | 198 | |
| 199 | function 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 | ||
| 177 | 253 | function clampFileLine(contents: string, requestedLine: number): number { |
| 178 | 254 | let lineCount = 1; |
| 179 | 255 | for (let index = 0; index < contents.length; index += 1) { |
| ... | ... | @@ -791,8 +867,10 @@ export default function FilePreviewPanel({ |
| 791 | 867 | const openPreview = useAtomCommand(previewEnvironment.open, { |
| 792 | 868 | reportFailure: false, |
| 793 | 869 | }); |
| 794 | const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); | |
| 795 | const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); | |
| 870 | const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); | |
| 871 | const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); | |
| 872 | const isMedia = isImage || isVideo; | |
| 873 | const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); | |
| 796 | 874 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 797 | 875 | // Reading markdown rendered is a preference, not a property of one file. Keeping |
| 798 | 876 | // it on the panel meant a thread switch dropped it and forced source back. |
| ... | ... | @@ -816,7 +894,10 @@ export default function FilePreviewPanel({ |
| 816 | 894 | (revealLine === null || |
| 817 | 895 | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); |
| 818 | 896 | const canOpenInBrowser = |
| 819 | relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); | |
| 897 | relativePath !== null && | |
| 898 | !isVideo && | |
| 899 | isPreviewSupportedInRuntime() && | |
| 900 | isBrowserPreviewFile(relativePath); | |
| 820 | 901 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; |
| 821 | 902 | const breadcrumbs = useMemo( |
| 822 | 903 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), |
| ... | ... | @@ -824,7 +905,7 @@ export default function FilePreviewPanel({ |
| 824 | 905 | ); |
| 825 | 906 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 826 | 907 | useWorkspaceMutationRefresh({ |
| 827 | enabled: relativePath !== null && !isImage && !selectedFilePending, | |
| 908 | enabled: relativePath !== null && !isMedia && !selectedFilePending, | |
| 828 | 909 | mutationId: workspaceMutationId, |
| 829 | 910 | refresh: file.refresh, |
| 830 | 911 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, |
| ... | ... | @@ -999,7 +1080,7 @@ export default function FilePreviewPanel({ |
| 999 | 1080 | </Tooltip> |
| 1000 | 1081 | </div> |
| 1001 | 1082 | ) : null} |
| 1002 | {relativePath && file.data?.truncated ? ( | |
| 1083 | {relativePath && !isMedia && file.data?.truncated ? ( | |
| 1003 | 1084 | <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground"> |
| 1004 | 1085 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. |
| 1005 | 1086 | </div> |
| ... | ... | @@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({ |
| 1011 | 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 | 1106 | <WorkspaceImagePreview |
| 1016 | 1107 | key={absolutePath} |
| 1017 | 1108 | environmentId={environmentId} |
| 1018 | 1109 | threadRef={threadRef} |
| 1019 | 1110 | absolutePath={absolutePath} |
| 1111 | workspaceRoot={cwd} | |
| 1020 | 1112 | alt={relativePath} |
| 1021 | 1113 | workspaceMutationId={workspaceMutationId} |
| 1022 | 1114 | /> |
| ... | ... | @@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({ |
| 1099 | 1191 | selectedPathRevealId={revealRequestId} |
| 1100 | 1192 | onOpenFile={onOpenFile} |
| 1101 | 1193 | workspaceMutationId={workspaceMutationId} |
| 1102 | {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} | |
| 1194 | {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} | |
| 1103 | 1195 | /> |
| 1104 | 1196 | </aside> |
| 1105 | 1197 | ) : null} |
apps/web/src/components/files/projectFilesQueryState.ts+11-3| ... | ... | @@ -4,6 +4,10 @@ import type { |
| 4 | 4 | ProjectListEntriesResult, |
| 5 | 5 | ProjectReadFileResult, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { | |
| 8 | isWorkspaceImagePreviewPath, | |
| 9 | isWorkspaceVideoPreviewPath, | |
| 10 | } from "@t3tools/shared/filePreview"; | |
| 7 | 11 | import * as Cause from "effect/Cause"; |
| 8 | 12 | import * as Option from "effect/Option"; |
| 9 | 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -178,9 +182,13 @@ export function useProjectFileQuery( |
| 178 | 182 | relativePath: string | null, |
| 179 | 183 | enabled = true, |
| 180 | 184 | ): ProjectQueryState<ProjectReadFileResult> { |
| 181 | const atom = enabled | |
| 182 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 183 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 185 | const isMedia = | |
| 186 | relativePath !== null && | |
| 187 | (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); | |
| 188 | const atom = | |
| 189 | enabled && !isMedia | |
| 190 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 191 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 184 | 192 | const result = useAtomValue(atom); |
| 185 | 193 | const refreshAtom = useAtomRefresh(atom); |
| 186 | 194 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
apps/web/src/components/media/MediaActions.tsx created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | import { | |
| 2 | mediaReferenceFileName, | |
| 3 | type MediaReference, | |
| 4 | } from "@t3tools/client-runtime/media-reference"; | |
| 5 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 7 | import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import { useCallback, useRef, useState, type ReactElement } from "react"; | |
| 9 | ||
| 10 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 11 | import { readLocalApi } from "../../localApi"; | |
| 12 | import { assetEnvironment } from "../../state/assets"; | |
| 13 | import { readPreparedConnection } from "../../state/session"; | |
| 14 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | |
| 15 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 17 | import { downloadMedia, readMediaPng } from "./mediaContent"; | |
| 18 | ||
| 19 | export 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 | ||
| 28 | function 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. */ | |
| 35 | export 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 | ||
| 71 | type 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. */ | |
| 74 | export 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 @@ |
| 1 | import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | |
| 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; | |
| 3 | ||
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 6 | import { Button } from "../ui/button"; | |
| 7 | import { OpenMediaLink } from "./OpenMediaLink"; | |
| 8 | import { MediaActions, type MediaActionSource } from "./MediaActions"; | |
| 9 | ||
| 10 | interface 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. */ | |
| 27 | export 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 @@ |
| 1 | import { DownloadIcon, ExternalLinkIcon } from "lucide-react"; | |
| 2 | ||
| 3 | import { resolveExternalWebLinkHost } from "../chat/externalLinkContextMenu"; | |
| 4 | import { Button } from "../ui/button"; | |
| 5 | import { resolveProtocolRelativeMediaUrl } from "./mediaContent"; | |
| 6 | ||
| 7 | /** Navigates directly so the browser handles video playback and downloads, without fetching bytes. */ | |
| 8 | export 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. */ | |
| 2 | export 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. */ | |
| 10 | async 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. */ | |
| 29 | export 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. */ | |
| 42 | export 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 | 14 | * A pull request body, rendered with the app's markdown renderer plus a card for each upload |
| 15 | 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 — Mac | |
| 18 | * recordings are `video/quicktime`, which no Chromium decodes — and the desktop window's | |
| 19 | * `media-src` allows only `'self'`, the app scheme and `blob:`, so a remote source is refused | |
| 20 | * before a byte is fetched. Opening the host works for every format. | |
| 17 | * These upload URLs do not identify the media format. The card links to GitHub, where the | |
| 18 | * original upload can be opened or downloaded even when its codec cannot play in the client. | |
| 21 | 19 | */ |
| 22 | 20 | export function PullRequestMarkdown({ |
| 23 | 21 | text, |
apps/web/src/contextMenuFallback.ts+4| ... | ... | @@ -196,6 +196,10 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H |
| 196 | 196 | // dismiss it with the same result as an outside click or Escape. |
| 197 | 197 | let activeContextMenuDismiss: (() => void) | null = null; |
| 198 | 198 | |
| 199 | export function isContextMenuOpen(): boolean { | |
| 200 | return activeContextMenuDismiss !== null; | |
| 201 | } | |
| 202 | ||
| 199 | 203 | /** |
| 200 | 204 | * Closes the currently open fallback context menu, resolving its show() with |
| 201 | 205 | * 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { prepareVideoFirstFrame } from "./videoFirstFrame"; | |
| 4 | ||
| 5 | type PreviewVideo = Parameters<typeof prepareVideoFirstFrame>[0]; | |
| 6 | ||
| 7 | function 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 | ||
| 20 | describe("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. */ | |
| 2 | export 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 | 163 | |
| 164 | 164 | it("ignores external urls", () => { |
| 165 | 165 | expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); |
| 166 | expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull(); | |
| 166 | 167 | }); |
| 167 | 168 | |
| 168 | 169 | it("does not double-decode file URLs", () => { |
apps/web/src/markdown-links.ts+9-151| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | inlineCodeFilePathCandidate, | |
| 3 | isConventionalFilePosition, | |
| 4 | } from "@t3tools/client-runtime/markdown-links"; | |
| 5 | ||
| 1 | 6 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 2 | 7 | import { |
| 3 | 8 | isTerminalLinkActivation, |
| ... | ... | @@ -190,7 +195,7 @@ export function resolveMarkdownFileLinkTarget( |
| 190 | 195 | ): string | null { |
| 191 | 196 | if (!href) return null; |
| 192 | 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; | |
| 194 | 199 | |
| 195 | 200 | const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") |
| 196 | 201 | ? parseFileUrlHref(rawHref) |
| ... | ... | @@ -221,124 +226,6 @@ export function resolveMarkdownFileLinkTarget( |
| 221 | 226 | return resolvePathLinkTarget(pathWithPosition, cwd); |
| 222 | 227 | } |
| 223 | 228 | |
| 224 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | |
| 225 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | |
| 226 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; | |
| 227 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; | |
| 228 | const 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. | |
| 231 | const 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 | ]); | |
| 260 | const 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. | |
| 265 | const 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. | |
| 295 | const 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. */ | |
| 330 | function 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 | 230 | * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, |
| 344 | 231 | * `origin/main`) rather than deliberate link destinations, so auto-linking |
| ... | ... | @@ -349,33 +236,8 @@ export function resolveInlineCodeFileLinkMeta( |
| 349 | 236 | codeText: string, |
| 350 | 237 | cwd?: string, |
| 351 | 238 | ): MarkdownFileLinkMeta | null { |
| 352 | const trimmed = codeText.trim(); | |
| 353 | if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; | |
| 354 | ||
| 355 | // Windows drive/UNC paths keep their backslashes; any other backslashes are | |
| 356 | // relative Windows-style paths, which neither the shape checks nor the | |
| 357 | // downstream resolver understand — normalize them to forward slashes. | |
| 358 | const candidate = | |
| 359 | WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) | |
| 360 | ? trimmed | |
| 361 | : trimmed.replaceAll("\\", "/"); | |
| 362 | ||
| 363 | const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); | |
| 364 | if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; | |
| 365 | ||
| 366 | const hasExplicitPathShape = | |
| 367 | RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || | |
| 368 | candidate.startsWith("/") || | |
| 369 | WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || | |
| 370 | WINDOWS_UNC_PATH_PATTERN.test(candidate); | |
| 371 | if (!hasExplicitPathShape) { | |
| 372 | const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); | |
| 373 | const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; | |
| 374 | if (looksLikeHostname(firstSegment, hasPosition)) return null; | |
| 375 | if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { | |
| 376 | return null; | |
| 377 | } | |
| 378 | } | |
| 239 | const candidate = inlineCodeFilePathCandidate(codeText); | |
| 240 | if (candidate === null) return null; | |
| 379 | 241 | |
| 380 | 242 | const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); |
| 381 | 243 | if (resolved) return resolved; |
| ... | ... | @@ -383,11 +245,7 @@ export function resolveInlineCodeFileLinkMeta( |
| 383 | 245 | // `Makefile:12` — conventional extensionless names fail the generic |
| 384 | 246 | // markdown-link candidate patterns, but here the :line suffix already |
| 385 | 247 | // marked the span as a file reference. |
| 386 | if ( | |
| 387 | cwd && | |
| 388 | BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && | |
| 389 | EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) | |
| 390 | ) { | |
| 248 | if (cwd && isConventionalFilePosition(candidate)) { | |
| 391 | 249 | return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); |
| 392 | 250 | } |
| 393 | 251 | return null; |
apps/web/src/pierre-icons.ts+11| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | getBuiltInSpriteSheet, |
| 4 | 4 | type FileTreeIcons, |
| 5 | 5 | } from "@pierre/trees"; |
| 6 | import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; | |
| 6 | 7 | |
| 7 | 8 | export interface PierreIconResolution { |
| 8 | 9 | name: string; |
| ... | ... | @@ -13,6 +14,13 @@ const PIERRE_ICON_SPRITE_ID = "t3code-pierre-file-icon-sprite"; |
| 13 | 14 | |
| 14 | 15 | const T3_FILE_ICON_SPRITE = ` |
| 15 | 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 | 24 | <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32"> |
| 17 | 25 | <path d="M2 2H30V30H2" fill="#c12127" /> |
| 18 | 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 | 58 | "pnpm-lock.yaml": "t3-file-icon-pnpm", |
| 51 | 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 | 64 | } satisfies FileTreeIcons; |
| 54 | 65 | |
| 55 | 66 | const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); |
apps/web/src/state/use-atom-query-runner.ts+5-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { RegistryContext } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | executeAtomQuery, |
| 4 | type AtomCommandOptions, | |
| 4 | type AtomQueryOptions, | |
| 5 | 5 | type AtomCommandResult, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 7 | import { AsyncResult, type Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; |
| 9 | 9 | |
| 10 | 10 | export function useAtomQueryRunner<T, A, E>( |
| 11 | 11 | family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>, |
| 12 | options?: string | AtomCommandOptions, | |
| 12 | options?: string | AtomQueryOptions, | |
| 13 | 13 | ): (target: T) => Promise<AtomCommandResult<A, E>> { |
| 14 | 14 | const registry = useContext(RegistryContext); |
| 15 | 15 | const explicitLabel = typeof options === "string" ? options : options?.label; |
| 16 | 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); |
| 17 | 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); |
| 18 | const refresh = typeof options === "string" ? false : (options?.refresh ?? false); | |
| 18 | 19 | |
| 19 | 20 | return useCallback( |
| 20 | 21 | (target: T) => { |
| ... | ... | @@ -23,8 +24,9 @@ export function useAtomQueryRunner<T, A, E>( |
| 23 | 24 | label: explicitLabel ?? atom.label?.[0] ?? "atom query", |
| 24 | 25 | reportFailure, |
| 25 | 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 | 28 | The desktop bootstrap credential and command-line administrative bootstrap |
| 29 | 29 | credentials additionally grant `access:read access:write relay:write`. |
| 30 | 30 | |
| 31 | ## Media preview access | |
| 32 | ||
| 33 | Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for | |
| 34 | supported images and videos anywhere the environment's server account can read. A thread ID | |
| 35 | supplies the workspace for relative paths; absolute paths refer to the environment host, not the | |
| 36 | client. This follows the environment-wide authorization model rather than introducing per-project | |
| 37 | filesystem permissions. | |
| 38 | ||
| 39 | [`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular | |
| 40 | file, and validates the resolved file's literal extension. It opens the file and signs its canonical | |
| 41 | path and device/inode identity for one hour. The token grants access to that exact file, not adjacent | |
| 42 | files or its containing directory. Serving rechecks the canonical path, media type, and opened | |
| 43 | descriptor's identity, then streams full or partial responses from that descriptor. Replacing a | |
| 44 | file atomically requires a freshly signed URL; editing it in place does not. The existing workspace | |
| 45 | boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their | |
| 46 | separate asset resource. | |
| 47 | ||
| 48 | Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment | |
| 49 | can fetch that file until it expires. Clients should copy the authored reference, not the temporary | |
| 50 | URL. Responses use `nosniff`; SVG responses retain their restrictive sandbox policy. Video reads | |
| 51 | support byte ranges so playback does not require a complete download first. | |
| 52 | ||
| 53 | Host videos can change in place, so their responses use `private, no-store` and omit `ETag` | |
| 54 | and `Last-Modified`. File metadata cannot prove byte-for-byte identity for `If-Range`; advertising | |
| 55 | those validators would encourage native players to send conditional seeks that require a full | |
| 56 | response. Ordinary range requests receive partial responses. An explicitly supplied `If-Range` | |
| 57 | still falls back to a full response because no strong validator is available. Host image previews | |
| 58 | keep their private cache policy and weak metadata validators. | |
| 59 | ||
| 60 | The server serves media in place without importing it into attachment storage. Deletion makes | |
| 61 | future server reads fail, though an already loaded client or its cache can retain bytes. Native | |
| 62 | viewers may use temporary client-side files for display or explicit sharing; those are not durable | |
| 63 | environment copies. | |
| 64 | ||
| 31 | 65 | ## Authentication Flows |
| 32 | 66 | |
| 33 | 67 | ### Browser Session |
docs/user/composer.md+43-1| ... | ... | @@ -34,7 +34,7 @@ viewer and PDFs open the system chooser. |
| 34 | 34 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 35 | 35 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. |
| 36 | 36 | Supported videos show a thumbnail in the conversation and composer. |
| 37 | On iOS, received videos stream from their environment as they play. Supported formats and codecs | |
| 37 | On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs | |
| 38 | 38 | depend on the browser or device; you can save an unsupported video to open it in another app. |
| 39 | 39 | |
| 40 | 40 | On 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 | 52 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| 53 | 53 | when starting a thread or changing an existing thread's model. |
| 54 | 54 | |
| 55 | ## Images and videos in messages | |
| 56 | ||
| 57 | On web, desktop, and mobile, select a link to an image or video to open it inside T3 Code. | |
| 58 | Workspace image and video links open the file viewer. Links to media outside the workspace | |
| 59 | open a media preview. | |
| 60 | Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They | |
| 61 | stream from the environment as needed, rather than downloading the entire video before playback. | |
| 62 | Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; | |
| 63 | video embeds show a player with controls and an option to expand. Visible video previews load | |
| 64 | an initial frame when supported, but stay paused until you press Play. Video file references use | |
| 65 | a filmstrip icon. | |
| 66 | ||
| 67 | On web and desktop, hover over a preview to see its full file path or original URL. Right-click | |
| 68 | to copy that reference, save an image, or copy an image to the clipboard. Use the video player's | |
| 69 | built-in controls to download videos. If the player cannot decode a video, its error message | |
| 70 | offers a link to open the source in the browser. Workspace media also offers **Copy relative | |
| 71 | path** and **Open in file viewer**. These actions are available in expanded previews too. | |
| 72 | ||
| 73 | On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its | |
| 74 | source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file | |
| 75 | viewer from the same menu. Saving downloads a copy only when you request it; it does not change | |
| 76 | how the video buffers during playback. | |
| 77 | ||
| 78 | Use Markdown image syntax to embed either kind of media: | |
| 79 | ||
| 80 | ```markdown | |
| 81 |  | |
| 82 |  | |
| 83 | [Open recording](/tmp/recording.mp4) | |
| 84 | ``` | |
| 85 | ||
| 86 | Relative paths resolve from the thread's workspace. Absolute paths and `file://` links refer to | |
| 87 | the environment's machine, even when you connect remotely or use your phone. Supported media | |
| 88 | can live outside the workspace, including in Downloads or `/tmp`. | |
| 89 | ||
| 90 | T3 Code serves the original file without adding it to attachment storage. If that file is moved | |
| 91 | or deleted, its preview can no longer load from the environment. A browser or device may still | |
| 92 | have a cached copy. Supported video formats and codecs depend on the browser or device. | |
| 93 | ||
| 94 | Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags | |
| 95 | are not supported; use the Markdown embed syntax above. | |
| 96 | ||
| 55 | 97 | ## Changing projects |
| 56 | 98 | |
| 57 | 99 | On 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 | 19 | "types": "./src/markdownImages.ts", |
| 20 | 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 | 30 | "./codex-file-citations": { |
| 23 | 31 | "types": "./src/codexFileCitations.ts", |
| 24 | 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 | 67 | } |
| 68 | 68 | |
| 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 | 71 | * Filesystem paths belong to the environment host and must never reach a |
| 72 | 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js"; | |
| 4 | ||
| 5 | describe("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 | ||
| 23 | describe("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 @@ |
| 1 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 2 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | |
| 3 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | |
| 4 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | |
| 5 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | |
| 6 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | |
| 7 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; | |
| 8 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; | |
| 9 | const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; | |
| 10 | // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. | |
| 11 | const 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 | ]); | |
| 40 | const 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. | |
| 43 | const 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. | |
| 72 | const 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 | ||
| 106 | function 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. */ | |
| 119 | export 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 | */ | |
| 130 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { mediaFileReference, mediaReferenceFileName } from "./mediaReference.ts"; | |
| 4 | ||
| 5 | describe("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 | ||
| 28 | describe("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 @@ |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 2 | ||
| 3 | /** The authored media location, never the temporary URL used to load its bytes. */ | |
| 4 | export type MediaReference = | |
| 5 | | { | |
| 6 | readonly kind: "file"; | |
| 7 | readonly path: string; | |
| 8 | readonly relativePath?: string; | |
| 9 | } | |
| 10 | | { readonly kind: "url"; readonly url: string }; | |
| 11 | ||
| 12 | function 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. */ | |
| 33 | export 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. */ | |
| 61 | export 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. */ | |
| 76 | export 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 | 58 | }), |
| 59 | 59 | ).toEqual({ |
| 60 | 60 | resource: { |
| 61 | _tag: "workspace-file", | |
| 61 | _tag: "media-file", | |
| 62 | 62 | threadId, |
| 63 | 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 | 99 | } |
| 100 | 100 | |
| 101 | 101 | export interface ViewedImageAsset { |
| 102 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 102 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; | |
| 103 | 103 | readonly alt: string; |
| 104 | 104 | readonly srcFragment: string; |
| 105 | 105 | } |
| ... | ... | @@ -129,7 +129,7 @@ export function resolveViewedImageAsset( |
| 129 | 129 | return { |
| 130 | 130 | resource: attachmentId |
| 131 | 131 | ? { _tag: "attachment", attachmentId } |
| 132 | : { _tag: "workspace-file", threadId: input.threadId, path }, | |
| 132 | : { _tag: "media-file", threadId: input.threadId, path }, | |
| 133 | 133 | alt: path.split(/[\\/]/).at(-1) ?? "image", |
| 134 | 134 | srcFragment: markdownImageSourceFragment(source), |
| 135 | 135 | }; |
packages/contracts/src/assets.ts+13-3| ... | ... | @@ -15,6 +15,10 @@ export const AssetResource = Schema.Union([ |
| 15 | 15 | threadId: ThreadId, |
| 16 | 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 | 22 | Schema.TaggedStruct("attachment", { |
| 19 | 23 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), |
| 20 | 24 | /** Display name and mime from the `ChatAttachment` the caller holds. The |
| ... | ... | @@ -152,7 +156,9 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass |
| 152 | 156 | }, |
| 153 | 157 | ) { |
| 154 | 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 | } |
| 158 | 164 | |
| ... | ... | @@ -164,7 +170,9 @@ export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass< |
| 164 | 170 | }, |
| 165 | 171 | ) { |
| 166 | 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 | } |
| 170 | 178 | |
| ... | ... | @@ -175,7 +183,9 @@ export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<As |
| 175 | 183 | }, |
| 176 | 184 | ) { |
| 177 | 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 | } |
| 181 | 191 |
packages/shared/src/filePreview.test.ts+27| ... | ... | @@ -4,6 +4,8 @@ import { |
| 4 | 4 | isWorkspaceBrowserPreviewPath, |
| 5 | 5 | isWorkspaceImagePreviewPath, |
| 6 | 6 | isWorkspacePreviewEntryPath, |
| 7 | isWorkspaceVideoPreviewPath, | |
| 8 | mediaKindFromPath, | |
| 7 | 9 | } from "./filePreview.ts"; |
| 8 | 10 | |
| 9 | 11 | describe("workspace file previews", () => { |
| ... | ... | @@ -34,3 +36,28 @@ describe("workspace file previews", () => { |
| 34 | 36 | }, |
| 35 | 37 | ); |
| 36 | 38 | }); |
| 39 | ||
| 40 | describe("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 | import { videoMimeType } from "./video.ts"; | |
| 2 | ||
| 1 | 3 | export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; |
| 2 | 4 | |
| 3 | 5 | export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ |
| ... | ... | @@ -11,6 +13,57 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ |
| 11 | 13 | ".webp", |
| 12 | 14 | ] as const; |
| 13 | 15 | |
| 16 | const 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. */ | |
| 28 | export 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. */ | |
| 37 | export 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 | ||
| 61 | export 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 | ||
| 14 | 67 | function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean { |
| 15 | 68 | const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; |
| 16 | 69 | return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); |
| ... | ... | @@ -24,6 +77,11 @@ export function isWorkspaceImagePreviewPath(path: string): boolean { |
| 24 | 77 | return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); |
| 25 | 78 | } |
| 26 | 79 | |
| 80 | /** File viewers receive literal filesystem paths, not Markdown URLs. */ | |
| 81 | export function isWorkspaceVideoPreviewPath(path: string): boolean { | |
| 82 | return videoMimeType({ name: path, mimeType: "" }) !== null; | |
| 83 | } | |
| 84 | ||
| 27 | 85 | export function isWorkspacePreviewEntryPath(path: string): boolean { |
| 28 | 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 | 8 | ["webm", "video/webm"], |
| 9 | 9 | ]); |
| 10 | 10 | |
| 11 | export const VIDEO_FILE_EXTENSIONS = Object.freeze([...VIDEO_MIME_TYPE_BY_EXTENSION.keys()]); | |
| 12 | ||
| 11 | 13 | /** Recognizes videos even when the file picker omitted their MIME type. */ |
| 12 | 14 | export function videoMimeType(attachment: { |
| 13 | 15 | readonly name: string; |
pnpm-lock.yaml+6-2| ... | ... | @@ -273,7 +273,7 @@ importers: |
| 273 | 273 | version: link:../../packages/contracts |
| 274 | 274 | '@t3tools/mobile-markdown-text': |
| 275 | 275 | 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 | 277 | '@t3tools/mobile-review-diff-native': |
| 278 | 278 | specifier: file:./modules/t3-review-diff |
| 279 | 279 | version: file:apps/mobile/modules/t3-review-diff |
| ... | ... | @@ -4433,6 +4433,8 @@ packages: |
| 4433 | 4433 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': |
| 4434 | 4434 | resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} |
| 4435 | 4435 | peerDependencies: |
| 4436 | '@t3tools/client-runtime': '*' | |
| 4437 | '@t3tools/shared': '*' | |
| 4436 | 4438 | expo-asset: '*' |
| 4437 | 4439 | expo-clipboard: '*' |
| 4438 | 4440 | expo-haptics: '*' |
| ... | ... | @@ -14253,8 +14255,10 @@ snapshots: |
| 14253 | 14255 | dependencies: |
| 14254 | 14256 | defer-to-connect: 2.0.1 |
| 14255 | 14257 | |
| 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 | 14259 | dependencies: |
| 14260 | '@t3tools/client-runtime': link:packages/client-runtime | |
| 14261 | '@t3tools/shared': link:packages/shared | |
| 14258 | 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 | 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 | 14264 | expo-haptics: 57.0.2(expo@57.0.18) |