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