From 922bd692251bc803c12a3fab159efe83c957bb70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:37:41 -0700 Subject: [PATCH] refactor(media): unify file and media previews across clients (#9253) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- .../t3-markdown-text/src/markdownLinks.ts | 156 +------- .../ios/T3NativeFilePresentation.swift | 2 + .../components/ComposerAttachmentStrip.tsx | 55 +-- .../mobile/src/components/FilePreview.ios.tsx | 7 +- .../src/components/MediaActionsMenu.tsx | 4 +- .../src/components/MediaImagePreview.tsx | 2 +- .../src/components/MediaVideoPlayer.tsx | 81 ++--- .../src/components/MediaVideoPreviewModal.tsx | 96 ----- .../src/components/VideoAttachmentMenu.tsx | 53 --- .../src/components/VideoAttachmentTile.tsx | 98 ++--- .../src/components/VideoPreviewModal.ios.tsx | 58 ++- .../src/components/VideoPreviewModal.tsx | 335 ++++++++---------- .../features/files/ThreadFilesRouteScreen.tsx | 2 +- .../files/WorkspaceFileImagePreview.tsx | 113 ++---- .../files/WorkspaceFileVideoPreview.tsx | 7 - .../files/workspace-file-image-cache.test.ts | 64 ---- .../files/workspace-file-image-cache.ts | 48 --- .../src/features/threads/ThreadFeed.tsx | 158 +++++---- apps/mobile/src/lib/markdownMedia.test.ts | 11 + apps/mobile/src/lib/markdownMedia.ts | 66 +--- apps/mobile/src/lib/mediaActions.ts | 119 ++++--- apps/mobile/src/lib/videoPreviewSource.ts | 63 +++- apps/mobile/src/state/assets.ts | 40 +-- apps/web/src/assets/assetUrls.ts | 59 ++- apps/web/src/components/ChatMarkdown.tsx | 24 -- .../web/src/components/ChatView.logic.test.ts | 9 - apps/web/src/components/ChatView.logic.ts | 9 - apps/web/src/components/ChatView.tsx | 65 +--- apps/web/src/components/chat/ChatComposer.tsx | 16 +- .../components/chat/ExpandedImageDialog.tsx | 60 ++-- .../components/chat/ExpandedImagePreview.tsx | 90 ++--- .../components/chat/MessagesTimeline.test.tsx | 47 ++- .../src/components/chat/MessagesTimeline.tsx | 93 ++--- .../web/src/components/media/MediaActions.tsx | 39 +- .../src/components/media/MediaVideoPlayer.tsx | 36 +- apps/web/src/filePathDisplay.ts | 27 +- apps/web/src/markdown-links.test.ts | 9 +- apps/web/src/markdown-links.ts | 239 ++----------- apps/web/src/terminal-links.ts | 40 +-- docs/internals/mobile-navigation.md | 19 +- docs/user/composer.md | 22 +- packages/client-runtime/package.json | 8 + packages/client-runtime/src/markdownImages.ts | 80 ++--- .../client-runtime/src/markdownLinks.test.ts | 142 +++++++- packages/client-runtime/src/markdownLinks.ts | 205 ++++++++++- packages/client-runtime/src/mediaActions.ts | 8 + packages/client-runtime/src/mediaReference.ts | 9 +- .../client-runtime/src/mediaSource.test.ts | 141 ++++++++ packages/client-runtime/src/mediaSource.ts | 104 ++++++ packages/client-runtime/src/state/assets.ts | 38 +- .../src/work-log/presentation.test.ts | 16 +- .../src/work-log/presentation.ts | 34 +- 52 files changed, 1584 insertions(+), 1742 deletions(-) delete mode 100644 apps/mobile/src/components/MediaVideoPreviewModal.tsx delete mode 100644 apps/mobile/src/components/VideoAttachmentMenu.tsx delete mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.test.ts delete mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.ts create mode 100644 packages/client-runtime/src/mediaActions.ts create mode 100644 packages/client-runtime/src/mediaSource.test.ts create mode 100644 packages/client-runtime/src/mediaSource.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 0caa24c3404fa3b41a25ec7941573b86dbb959d3..17658534416720af517f8df24955e320dc8e6ce1 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,31 +1,15 @@ import { + fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, - isConventionalFilePosition, + normalizeMarkdownLinkDestination, + parseMarkdownFileLink, } from "@t3tools/client-runtime/markdown-links"; import { videoMimeType } from "@t3tools/shared/video"; import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; -const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSIX_FILE_ROOT_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/etc/", - "/opt/", - "/mnt/", - "/Volumes/", - "/private/", - "/root/", -] as const; export type MarkdownLinkPresentation = | { @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly> = { zsh: "bash", }; -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function normalizeDestination(value: string): string { - const trimmed = value.trim(); - return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; -} - /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ export function normalizeNativeMarkdownUrl(value: string): string { return value.startsWith("//") ? `https:${value}` : value; } -function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") { - return null; - } - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const rawPath = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; - return { path, hash: parsed.hash }; - } catch { - return null; - } -} - -function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { - const hashIndex = value.indexOf("#"); - const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; - const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; - const queryIndex = pathWithSearch.indexOf("?"); - return { - path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, - hash, - }; -} - -function splitFilePosition( - path: string, - hash: string, -): { readonly path: string; readonly line?: number; readonly column?: number } { - const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); - const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); - const match = suffixMatch ?? hashMatch; - if (!match?.[1]) { - return { path }; - } - - const line = Number.parseInt(match[1], 10); - const column = match[2] ? Number.parseInt(match[2], 10) : undefined; - const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; - return { - path: pathWithoutPosition, - ...(line > 0 ? { line } : {}), - ...(column !== undefined && column > 0 ? { column } : {}), - }; -} - -function looksLikePosixFilesystemPath(path: string): boolean { - if (!path.startsWith("/")) { - return false; - } - if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { - return true; - } - if (POSITION_SUFFIX_PATTERN.test(path)) { - return true; - } - const basename = path.slice(path.lastIndexOf("/") + 1); - return /\.[A-Za-z0-9_-]+$/.test(basename); -} - -function looksLikeFilePath(value: string): boolean { - if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { - return true; - } - if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { - return true; - } - if (value.startsWith("/")) { - return looksLikePosixFilesystemPath(value); - } - if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { - return true; - } - if (isConventionalFilePosition(value)) return true; - return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); -} - -function fileLabel(value: string): string { - const normalized = value.replaceAll("\\", "/"); - const basename = normalized.slice(normalized.lastIndexOf("/") + 1); - return basename || normalized; -} - export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { - const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { } export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { - const normalized = normalizeDestination(href); + const normalized = normalizeMarkdownLinkDestination(href); try { const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese // Relative paths and non-URL link destinations are handled below. } - const source = normalized.toLowerCase().startsWith("file:") - ? fileUrlTarget(normalized) - : stripSearchAndHash(normalized); - const decodedSource = source - ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } - : null; - const fileTarget = decodedSource - ? splitFilePosition(decodedSource.path, decodedSource.hash) - : null; - const targetWithPosition = fileTarget - ? `${fileTarget.path}${ - fileTarget.line - ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` - : "" - }` - : null; - if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { + const target = parseMarkdownFileLink(normalized); + if (target) { return { kind: "file", href: normalized, - icon: resolveMarkdownFileIcon(fileTarget.path), - label: fileLabel(targetWithPosition), - path: fileTarget.path, - ...(fileTarget.line ? { line: fileTarget.line } : {}), - ...(fileTarget.column ? { column: fileTarget.column } : {}), + icon: resolveMarkdownFileIcon(target.path), + label: fileBasename(formatFilePathPosition(target)), + path: target.path, + ...(target.line ? { line: target.line } : {}), + ...(target.column ? { column: target.column } : {}), }; } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift index 1a7009c3821dfecc76c8f2bf812beab6707ffea3..45954053125b0a96318ac47850181811b9c88978 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, type = detectedType } else if CGPDFDocument(download as CFURL) != nil { type = .pdf + } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { + type = .svg } else { throw URLError(.cannotDecodeContentData) } diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 16f0d422af78df3327569cf883dda60e4b9352c6..b5d86c44109d2af31bb1c2065c0243befac21ce2 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,12 +1,12 @@ import { SymbolView } from "../components/AppSymbol"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useRef, useState } from "react"; -import { Alert, Image, Pressable, ScrollView, View } from "react-native"; +import { useMemo } from "react"; +import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const shareRef = useRef(null); - const [sharing, setSharing] = useState(false); - useEffect( - () => () => { - shareRef.current?.abort(); - shareRef.current = null; - }, - [], + const actionsSource = useMemo( + () => ({ + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }), + [attachment, sourceIdentifier], ); - const onShare = () => { - if (shareRef.current) return; - const controller = new AbortController(); - shareRef.current = controller; - setSharing(true); - void (async () => { - const preview = await loadLocalAttachmentPreview(attachment, controller.signal); - if (!preview) return; - try { - await preview.share(controller.signal, sourceIdentifier); - } finally { - preview.dispose(); - } - })() - .catch((error: unknown) => { - if (!controller.signal.aborted) { - Alert.alert( - "Could not share video", - error instanceof Error ? error.message : "Try again.", - ); - } - }) - .finally(() => { - if (shareRef.current === controller) { - shareRef.current = null; - setSharing(false); - } - }); - }; - return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={onShare} - disabled={sharing} + actionsSource={actionsSource} style={style} /> ); diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c2f5a6d72cc718bb4136ccffa8568715ae005e5f..af943b8be9a2a561248f9f7500e3baaba145f041 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; -import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -47,9 +46,5 @@ export function FilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { - return props.source.kind === "image" && props.source.actionsSource ? ( - - ) : ( - - ); + return ; } diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx index a4b44e85258f2ebc2a48514862f6167d546d5e81..f54e1378ba8040873015fbd9cd2fe31bcfcac9fb 100644 --- a/apps/mobile/src/components/MediaActionsMenu.tsx +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -1,6 +1,6 @@ import { MenuView } from "@react-native-menu/menu"; import type { ReactElement } from "react"; -import { Platform, View, type PressableProps } from "react-native"; +import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import type { useMediaActions } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { readonly media: ReturnType; readonly inModal?: boolean; readonly children?: ReactElement; + readonly style?: StyleProp; }) { if (props.media.actions.length === 0) return props.children ?? null; // Android's normal anchored menu lives in the app-root portal, behind native modals. @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { return ( ({ id, diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 5bdc9140ddc9ba2f04c4d0066be0c49d9a8e236b..03e317c296af7ee42974fe403cf4000d0fa74114 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -42,7 +42,7 @@ function ImagePreviewHeader() { ); } -/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +/** Android keeps media actions in its in-app image viewer. iOS uses Quick Look. */ export function MediaImagePreview(props: MediaImagePreviewProps) { return ( diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index a065f75e13961058a3ee803a12dd2333ca232936..0e3d8416affa1d6dcc0b310ff15f7ea34a255f2b 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { readonly thumbnailVisible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + readonly autoPlay?: boolean; readonly paused?: boolean; - readonly onExpand?: () => void; readonly actionsSource?: MediaActionsSource; } @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { ) : ( - setPlaybackUri(props.uri)} - className="flex-1 items-center justify-center gap-2 px-4" - > - {!props.unavailable ? ( - - ) : null} - {props.unavailable ? ( - Video unavailable - ) : props.uri === null ? ( - - ) : ( - <> + + 0 ? "Touch and hold for media actions" : undefined + } + accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} + // Stays pressable so the long-press menu still opens on a failed or unsigned tile. + onPress={() => { + if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + }} + className="flex-1 items-center justify-center px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + )} + {props.uri !== null && !props.unavailable ? ( {props.name} - - )} - + ) : null} + + )} - {props.onExpand ? ( - { - setPlaybackUri(null); - props.onExpand?.(); - }} - className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" - > - Expand - - ) : null} - {props.actionsSource ? ( - - - - ) : null} ); } diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx deleted file mode 100644 index 6c231194701cd76af62d45c42dd24fda77c1aac6..0000000000000000000000000000000000000000 --- a/apps/mobile/src/components/MediaVideoPreviewModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect } from "react"; -import { Keyboard, Modal, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { useMediaActions } from "../lib/mediaActions"; -import { MediaActionsMenu } from "./MediaActionsMenu"; -import { - mediaVideoPreviewUri, - mediaVideoThumbnailKey, - type MediaVideoPreviewSource, -} from "../lib/videoPreviewSource"; -import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; -import { AppText } from "./AppText"; -import { SymbolView } from "./AppSymbol"; -import { MediaVideoPlayer } from "./MediaVideoPlayer"; -import { MediaSourceCaption } from "./MediaSourceCaption"; - -/** Media files stream in place. A client-side copy is made only for an explicit share. */ -export function MediaVideoPreviewModal(props: { - readonly source: MediaVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; - const insets = useSafeAreaInsets(); - const environmentId = "environmentId" in source ? source.environmentId : null; - const connection = usePreparedConnection(environmentId); - const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); - const refreshAssetUrl = useRefreshAssetUrl( - environmentId, - "resource" in source ? source.resource : null, - ); - const resolvePlaybackUri = - "resource" in source - ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) - : undefined; - const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); - const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); - const unavailable = - uri === null && - environmentId !== null && - (connection._tag === "None" || asset._tag === "Failure"); - - useEffect(() => Keyboard.dismiss(), []); - return ( - - - - - {source.name} - - - - - - - - - - - {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} - - - - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx deleted file mode 100644 index 301d6503a50813188af6cfeb4856a56b9288c699..0000000000000000000000000000000000000000 --- a/apps/mobile/src/components/VideoAttachmentMenu.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ReactElement } from "react"; -import { Platform, type PressableProps } from "react-native"; - -import { ControlPillMenu } from "./ControlPill"; -import { PresentationSource } from "./NativePresentation"; - -export function VideoAttachmentMenu(props: { - readonly sourceIdentifier: string; - readonly onOpen: () => void; - readonly onShare?: () => void; - readonly disabled?: boolean; - readonly children: ReactElement; -}) { - return ( - { - if (!props.disabled) props.onOpen(); - }} - accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} - onAccessibilityAction={({ nativeEvent }) => { - if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); - }} - > - {Platform.OS === "ios" && props.onShare ? ( - { - if (nativeEvent.event === "share") props.onShare?.(); - }} - > - {props.children} - - ) : ( - props.children - )} - - ); -} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx index 6f582ac5f00599cb932d19a4a60bbfee55a8217e..f8c7e2c1cbdc7500b66c96ec670212e1e847e92b 100644 --- a/apps/mobile/src/components/VideoAttachmentTile.tsx +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react import { cn } from "../lib/cn"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { PresentationSource } from "./NativePresentation"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; export function VideoAttachmentTile(props: { readonly name: string; readonly sourceIdentifier: string; readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly actionsSource?: MediaActionsSource; readonly compact?: boolean; readonly onPress: (sourceIdentifier: string) => void; - readonly onShare?: () => void; readonly disabled?: boolean; readonly className?: string; readonly style?: StyleProp; }) { + const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource); + const hasActions = mediaActions.actions.length > 0; return ( - props.onPress(props.sourceIdentifier)} - onShare={props.onShare} - disabled={props.disabled} + { + if (!props.disabled) props.onPress(props.sourceIdentifier); + }} + accessibilityActions={mediaActions.actions.map(({ id, title }) => ({ + name: id, + label: title, + }))} + onAccessibilityAction={({ nativeEvent }) => { + if (props.disabled) return; + mediaActions.actions.find(({ id }) => id === nativeEvent.actionName)?.run(); + }} > - props.onPress(props.sourceIdentifier)} - className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} - style={props.style} - > - - + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} > - - - {!props.compact ? ( - - - {props.name} - + + + - ) : null} - - + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); } diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 88a1b5191dd05f51f5ca4c0b198d84811e913076..ae69e0ead89a999edcbf5c1e621b387abc3ec6b1 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -1,14 +1,12 @@ import { useIsFocused } from "@react-navigation/native"; -import { videoMimeType } from "@t3tools/shared/video"; import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useState } from "react"; import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { useAssetUrlState } from "../state/assets"; +import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: AttachmentVideoPreviewSource; + readonly source: VideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; - const { attachment } = source; + const localAttachment = source.type === "local" ? source.attachment : null; const identifier = useId(); const onRequestClose = useEffectEvent(props.onRequestClose); - const environmentId = source.type === "remote" ? source.environmentId : null; + const environmentId = + source.type === "media" && "environmentId" in source ? source.environmentId : null; + const resource = source.type === "media" && "resource" in source ? source.resource : null; const preparedConnection = usePreparedConnection(environmentId); - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [playbackUrl, setPlaybackUrl] = useState(() => - assetUrl._tag === "Success" ? assetUrl.url : null, - ); + const assetUrl = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); + const name = source.type === "media" ? source.name : source.attachment.name; + // The first minted URL is kept so a background refresh does not restart playback. + const resolvedUrl = + source.type === "media" + ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) + : null; + const [playbackUrl, setPlaybackUrl] = useState(resolvedUrl); const loadError = - source.type === "remote" && playbackUrl === null + resource !== null && playbackUrl === null ? preparedConnection._tag === "None" ? "Reconnect to this environment and open the video again." : assetUrl._tag === "Failure" @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); - }, [playbackUrl, assetUrl]); + if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); + }, [playbackUrl, resolvedUrl]); useEffect(() => { if (!loadError) return; Alert.alert("Could not open video", loadError); @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { }, [loadError]); useEffect(() => { - if (source.type === "remote" && playbackUrl === null) return; + if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); let ready = false; void (async () => { const file = - source.type === "local" - ? await loadLocalAttachmentPreview(source.attachment, controller.signal) + localAttachment !== null + ? await loadLocalAttachmentPreview(localAttachment, controller.signal) : null; - if (source.type === "local" && !file) return; + if (localAttachment !== null && !file) return; try { if (controller.signal.aborted) return; ready = true; await NativeControls.presentVideo( file?.uri ?? playbackUrl!, - attachment.name, + name, source.sourceIdentifier ?? "", identifier, ); @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { } })().catch((error: unknown) => { if (controller.signal.aborted) return; + // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. + if (ready) void refreshAssetUrl(); Alert.alert( "Could not open video", ready - ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original." : error instanceof Error ? error.message : "Could not load this video.", @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { controller.abort(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; - }, [source, attachment.name, playbackUrl, identifier]); + }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); return null; } @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; - if (props.source.type === "media") { - return ; - } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index cc56b3952b75aa6614817e2a5e92d2f6b972c977..2a585b5e1a30cf676ada207440ddb42528174632 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,190 +1,139 @@ import { useIsFocused } from "@react-navigation/native"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEvent } from "expo"; -import { useVideoPlayer, VideoView } from "expo-video"; import { useEffect, useRef, useState } from "react"; -import { - ActivityIndicator, - AppState, - Keyboard, - Modal, - Pressable, - StyleSheet, - View, -} from "react-native"; +import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - downloadAttachmentForPreview, - type AttachmentPreviewFile, -} from "../lib/attachmentDownload"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; -import { useAssetUrlState } from "../state/assets"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type LocalVideoPreviewSource, + type MediaVideoPreviewSource, + type VideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; -import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; +import { SymbolView } from "./AppSymbol"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; -function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { - const player = useVideoPlayer(props.file.uri, (player) => { - player.staysActiveInBackground = false; - if (AppState.currentState === "active") player.play(); - }); - const { status } = useEvent(player, "statusChange", { status: player.status }); - const shareControllerRef = useRef(null); - const [sharing, setSharing] = useState(false); - const [shareError, setShareError] = useState(null); +interface PlaybackState { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly unavailable: boolean; + readonly error: string | null; + readonly actionsSource: MediaActionsSource | undefined; +} - useEffect( - () => () => { - shareControllerRef.current?.abort(); - shareControllerRef.current = null; - }, - [], - ); - - const onShare = () => { - if (shareControllerRef.current) return; - player.pause(); - const controller = new AbortController(); - shareControllerRef.current = controller; - setSharing(true); - setShareError(null); - void props.file - .share(controller.signal) - .catch((error: unknown) => { - if (!controller.signal.aborted) { - setShareError(error instanceof Error ? error.message : "Could not share this video."); - } - }) - .finally(() => { - if (shareControllerRef.current === controller) { - shareControllerRef.current = null; - setSharing(false); - } - }); +function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { + const environmentId = "environmentId" in source ? source.environmentId : null; + const resource = "resource" in source ? source.resource : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return { + uri, + ...(resource !== null + ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } + : {}), + unavailable: + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"), + error: null, + actionsSource: source.actionsSource, }; - - return ( - <> - - {status === "error" ? ( - - This video couldn't be played on this device. You can save or share the original file. - - ) : ( - <> - - {status === "loading" ? ( - - ) : null} - - )} - - - - {sharing ? "Opening share sheet..." : "Save or share video"} - - - {shareError ? ( - - {shareError} - - ) : null} - - ); } -function OpenVideoPreviewModal(props: { - readonly source: AttachmentVideoPreviewSource; - readonly onRequestClose: () => void; -}) { - const { source } = props; +function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { const { attachment } = source; - const insets = useSafeAreaInsets(); - const environmentId = source.type === "remote" ? source.environmentId : null; - const preparedConnection = usePreparedConnection(environmentId); - const fileUri = source.type === "local" ? source.attachment.fileUri : null; - const mimeType = videoMimeType(attachment) ?? attachment.mimeType; - const assetUrl = useAssetUrlState( - environmentId, - source.type === "remote" - ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } - : null, - ); - const [downloadUrl, setDownloadUrl] = useState(null); - const [file, setFile] = useState(null); - const [failure, setFailure] = useState(null); - - useEffect(() => Keyboard.dismiss(), []); + const [uri, setUri] = useState(null); + const [error, setError] = useState(null); + // Only a different file needs a new lease; a metadata update on the same + // draft must not dispose the file Android is still playing. + const attachmentRef = useRef(attachment); + attachmentRef.current = attachment; + const { id: attachmentId, fileUri } = attachment; useEffect(() => { - if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { - setDownloadUrl(assetUrl.url); - } - }, [environmentId, downloadUrl, assetUrl]); - - useEffect(() => { - if (source.type === "remote" && downloadUrl === null) return; + setUri(null); + setError(null); const controller = new AbortController(); - let preview: AttachmentPreviewFile | null = null; - setFile(null); - setFailure(null); - const loading = - source.type === "local" - ? loadLocalAttachmentPreview(source.attachment, controller.signal) - : downloadAttachmentForPreview({ - url: downloadUrl!, - attachment: { name: attachment.name, mimeType }, - signal: controller.signal, - }); + const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); void loading.then( - (loaded) => { - if (controller.signal.aborted) { - loaded?.dispose(); - return; - } - preview = loaded; - setFile(loaded); + (file) => { + if (file === null) return; + if (controller.signal.aborted) file.dispose(); + else setUri(file.uri); }, - (error: unknown) => { + (cause: unknown) => { if (!controller.signal.aborted) { - setFailure(error instanceof Error ? error.message : "Could not load this video."); + setError(cause instanceof Error ? cause.message : "Could not load this video."); } }, ); return () => { controller.abort(); - preview?.dispose(); + void loading.then( + (file) => file?.dispose(), + () => undefined, + ); }; - }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); - - const loadError = - failure ?? - (environmentId !== null && downloadUrl === null - ? preparedConnection._tag === "None" - ? "This environment is disconnected. Reconnect and open the video again." - : assetUrl._tag === "Failure" - ? "Could not load this video. Check the connection to this environment and try again." - : null - : null); + }, [attachmentId, fileUri]); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + return { + uri, + unavailable: error !== null, + error, + actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri }, + }; +} +function MediaPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} + +function LocalPreviewModal(props: { + readonly source: LocalVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + return ( + + ); +} + +function OpenVideoPreviewModal(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly playback: PlaybackState; + readonly onRequestClose: () => void; +}) { + const { playback } = props; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); + + useEffect(() => Keyboard.dismiss(), []); return ( - {attachment.name} + {props.name} + - {file ? ( - - ) : ( + + {playback.uri === null && !playback.unavailable ? ( - {loadError ? ( - - {loadError} - - ) : ( - <> - - Loading video... - - )} + + Loading video... + ) : ( + )} + {playback.error ? ( + + {playback.error} + + ) : null} + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + ); @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; - if (source.type === "media") { - return ; - } - const key = - source.type === "local" - ? `local:${source.attachment.id}:${source.attachment.fileUri}` - : `remote:${source.environmentId}:${source.attachment.id}`; - return ; + return source.type === "local" ? ( + + ) : ( + + ); } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index c682436cad832428ad2b984f9b89760fc1484725..8b7c3d35a3ad034d949a032f2cd0570d673b76b6 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { id: action.id, title: action.title, icon: - action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), inline: false, onPress: action.run, })) diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 3e6afae6f84ec096ad7469b37b91157809603bce..ae6987f4c1c8d9135fc8bdec8a36f97a79b2dd7d 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,11 +1,8 @@ -import { useAtomValue } from "@effect/atom-react"; import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import { workspaceFileImageAtom } from "./workspace-file-image-cache"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { return ( - - setPreview({ - kind: "image", - uri: props.uri, - name: props.accessibilityLabel, - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - > - - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> - - + + 0 ? "Touch and hold for media actions" : undefined + } + disabled={loadError !== null} + className="flex-1 p-4 active:bg-subtle-strong" + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + > + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + + + {loadError !== null ? ( - + ) : null} - - - - - setPreview(null)} /> ); } -function CachedWorkspaceFileImagePreview(props: { - readonly accessibilityLabel: string; - readonly uri: string; - readonly actionsSource?: MediaActionsSource; -}) { - const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); - const imageResult = useAtomValue(imageAtom); - - if (AsyncResult.isFailure(imageResult)) { - return ( - - - - ); - } - - if (!AsyncResult.isSuccess(imageResult)) { - return ( - - - Loading image... - - ); - } - - return ( - - ); -} - export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { } return ( - Promise; readonly unavailable: boolean; }) { - const [preview, setPreview] = useState(null); const uri = props.uri; if (props.unavailable) { @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { name={props.name} thumbnailKey={props.thumbnailKey} actionsSource={props.source?.actionsSource} - onExpand={ - uri === null || props.source === null ? undefined : () => setPreview(props.source) - } /> - setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts deleted file mode 100644 index 4acb67361a8a20f0c6bc05cc467711bf8d43eff1..0000000000000000000000000000000000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; - -describe("workspaceFileImageAtom", () => { - it("reuses a prefetched image across route remounts", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); - const registry = AtomRegistry.make({ timeoutResolution: 1 }); - const first = imageAtom("https://example.test/image.png"); - const firstUnmount = registry.mount(first); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - }); - firstUnmount(); - - const remounted = imageAtom("https://example.test/image.png"); - const secondUnmount = registry.mount(remounted); - - expect(remounted).toBe(first); - expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); - expect(prefetch).toHaveBeenCalledTimes(1); - - secondUnmount(); - registry.dispose(); - }); - - it("prefetches different asset URLs independently", async () => { - const prefetch = vi.fn(async () => true); - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); - const registry = AtomRegistry.make(); - const first = imageAtom("https://example.test/first.png"); - const second = imageAtom("https://example.test/second.png"); - const firstUnmount = registry.mount(first); - const secondUnmount = registry.mount(second); - - await vi.waitFor(() => { - expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); - expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); - }); - expect(prefetch).toHaveBeenCalledTimes(2); - - firstUnmount(); - secondUnmount(); - registry.dispose(); - }); - - it("exposes prefetch failures", async () => { - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); - const registry = AtomRegistry.make(); - const atom = imageAtom("https://example.test/missing.png"); - const unmount = registry.mount(atom); - - await vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); - }); - - unmount(); - registry.dispose(); - }); -}); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts deleted file mode 100644 index 3f58f65b46c9a1d7679411590ac88749837bed81..0000000000000000000000000000000000000000 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import { Atom } from "effect/unstable/reactivity"; - -const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; - -type ImagePrefetch = (uri: string) => Promise; - -class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} - -export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ - readonly cause?: unknown; - readonly uri: string; -}> {} - -async function prefetchWithNativeImage(uri: string): Promise { - const { Image } = await import("react-native"); - return Image.prefetch(uri); -} - -export function createWorkspaceFileImageAtomFamily(options?: { - readonly idleTtlMs?: number; - readonly prefetch?: ImagePrefetch; -}) { - const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; - const prefetch = options?.prefetch ?? prefetchWithNativeImage; - const family = Atom.family((key: WorkspaceImageCacheKey) => - Atom.make( - Effect.tryPromise({ - try: async () => { - const cached = await prefetch(key.uri); - if (!cached) { - throw new WorkspaceImagePrefetchError({ uri: key.uri }); - } - return key.uri; - }, - catch: (cause) => - cause instanceof WorkspaceImagePrefetchError - ? cause - : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), - }), - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), - ); - - return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); -} - -export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1b7cb5f373ecc75a9d8bad0aabbec1f228e80f15..65fa77f5414610ddb8a674513520a81a2ae1e5de 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { + attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; + readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); - const uri = useAssetUrl(props.environmentId, { - _tag: "attachment", - attachmentId: props.attachmentId, - }); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: props.attachmentId, + fileName: props.name, + mimeType: props.mimeType, + }), + [props.attachmentId, props.name, props.mimeType], + ); + const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => - props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + // The viewer mints its own URL from the resource so the image survives a refresh. + props.onPressPreview({ + kind: "image", + environmentId: props.environmentId, + resource, + name: props.name, + sourceIdentifier, + actionsSource: { + name: props.name, + mimeType: props.mimeType, + environmentId: props.environmentId, + resource, + }, + }) } > @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { }; if (videoType !== null) { + const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( props.onPressVideo(attachment, sourceIdentifier)} - onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( - - {failed ? ( - Image unavailable - ) : ( - - )} - {props.actionsSource ? ( - - - - ) : null} - + + 0 ? "Touch and hold for media actions" : undefined + } + className="items-center justify-center rounded-[10px] bg-md-code-bg" + style={frameStyle} + > + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( - - - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - style={{ alignSelf: "flex-start" }} + + 0 ? "Touch and hold for media actions" : undefined + } + onPress={() => + // Quick Look picks the viewer from the name's extension, so it needs the + // file name rather than the alt text. + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.actionsSource?.name ?? props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + - - setFailedUri(props.uri)} - /> - - - - {props.actionsSource ? ( - - + setFailedUri(props.uri)} + /> - ) : null} - + + )} {props.alt ? ( @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { - readonly source: MediaVideoPreviewSource; - readonly onExpand: (source: MediaVideoPreviewSource) => void; -}) { +function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { const { source } = props; const visible = useContext(ThreadMediaVisibleContext); const thumbnailKey = mediaVideoThumbnailKey(source); @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} - onExpand={() => props.onExpand(source)} /> ); } @@ -1593,6 +1612,7 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} + mimeType={attachment.mimeType} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" onPressPreview={props.onPressPreview} /> @@ -1656,6 +1676,7 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} + mimeType={attachment.mimeType} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> @@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo((current) => current ?? source)} /> ); } @@ -2667,12 +2687,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (attachment: ChatFileAttachment, sourceIdentifier: string) => { setExpandedVideo( (current) => - current ?? { - type: "remote", - environmentId: props.environmentId, - attachment, - sourceIdentifier, - }, + current ?? + attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), ); }, [props.environmentId], diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts index 77834630dc2f5800187365e54f396f3cd27effbb..1d7320eb06709e8b73a65d6a6f2abb6eb42fad0e 100644 --- a/apps/mobile/src/lib/markdownMedia.test.ts +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => { }); }); + it("serves a linked T3 attachment file in place like any other host path", () => { + const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4"; + expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({ + kind: "video", + source: { + resource: { _tag: "media-file", threadId: input.threadId, path }, + actionsSource: { resource: { _tag: "media-file", path } }, + }, + }); + }); + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { expect( resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index 2196c2f22f2b16f12ab046b344a8d3ef5701a007..b1b616f0e442765bb4769e4881b1d1fd0e089ada 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -1,15 +1,6 @@ -import { - classifyMarkdownImageSource, - markdownImageSourceFragment, -} from "@t3tools/client-runtime/markdown-images"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; -import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; -import { - mediaFileReference, - mediaReferenceFileName, - mediaUrlReference, -} from "@t3tools/client-runtime/media-reference"; import type { FilePreviewSource } from "../components/FilePreviewModal"; import type { MediaVideoPreviewSource } from "./videoPreviewSource"; @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( | { readonly kind: "image"; readonly source: FilePreviewSource } | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } | null { - const classified = classifyMarkdownImageSource(href, input.workspaceRoot); - if (classified._tag === "Blocked") return null; - const path = - classified._tag === "WorkspaceFile" - ? classified.path.replace(/:\d+(?::\d+)?$/, "") - : classified.uri.split(/[?#]/, 1)[0]!; - const basename = path.split(/[\\/]/).at(-1) ?? ""; - const extensionIndex = basename.lastIndexOf("."); - // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. - const detectedMimeType = - classified._tag === "Direct" - ? mediaMimeType(classified.uri) - : extensionIndex < 0 - ? null - : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); - const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); - if (mimeType === null) return null; - const kind = mimeType.startsWith("video/") ? "video" : "image"; - const reference = - classified._tag === "Direct" - ? mediaUrlReference(classified.uri) - : mediaFileReference(path, input.workspaceRoot); - const name = - (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); - const srcFragment = markdownImageSourceFragment(href); + const media = resolveMediaSource(href, input); + if (media === null || media.access === "unavailable") return null; + const { kind, name, mimeType, reference, srcFragment } = media; + const target = - classified._tag === "Direct" - ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + media.access === "direct" + ? { uri: normalizeNativeMarkdownUrl(media.uri) } : { environmentId: input.environmentId, - resource: { - _tag: "media-file" as const, - threadId: input.threadId, - path, - }, + resource: media.resource, ...(srcFragment ? { srcFragment } : {}), }; const actionsSource: MediaActionsSource = - classified._tag === "Direct" - ? { reference, uri: classified.uri, name, mimeType } + media.access === "direct" + ? { reference, uri: media.uri, name, mimeType } : { reference, environmentId: input.environmentId, threadId: input.threadId, - resource: { _tag: "media-file", threadId: input.threadId, path }, + resource: media.resource, name, mimeType, }; return kind === "video" - ? { - kind, - source: { type: "media", name, mimeType, ...target, actionsSource }, - } - : { - kind, - source: { kind, name, ...target, actionsSource }, - }; + ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } } + : { kind, source: { kind, name, ...target, actionsSource } }; } diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index 14dc2de7bd212847dd9ccceacedc02b914e5b796..c37ed76c2bf58707b2f7eff6afec0c993e23abc3 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -1,4 +1,5 @@ import { useNavigation } from "@react-navigation/native"; +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import type { MediaReference } from "@t3tools/client-runtime/media-reference"; import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; @@ -7,18 +8,23 @@ import { Alert } from "react-native"; import { useRefreshAssetUrl } from "../state/assets"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import type { DraftComposerFileAttachment } from "./composerImages"; import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; /** Authored source metadata is kept separate from temporary preview/download URLs. */ export type MediaActionsSource = { readonly reference?: MediaReference; readonly name: string; readonly mimeType: string; + /** Anchors the iOS share sheet to the view that opened the menu. */ + readonly sourceIdentifier?: string; } & ( | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } | { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; + readonly threadId?: ThreadId; readonly resource: AssetResource; } ); @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi controller.current = request; setSharing(true); void (async () => { + if ("attachment" in source) { + const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); + if (!preview) return; + try { + await preview.share(request.signal, source.sourceIdentifier); + } finally { + preview.dispose(); + } + return; + } const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); if (request.signal.aborted) return; if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); const input = { attachment: { name: source.name, mimeType: source.mimeType }, signal: request.signal, + sourceIdentifier: source.sourceIdentifier, }; if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); else await downloadAndShareAttachment({ ...input, url: uri }); @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi }; const reference = source?.reference; - const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source - ? [ - ...(reference?.kind === "file" - ? [ - { - id: "copy-path", - title: "Copy full path", - run: () => copyTextWithHaptic(reference.path), - }, - ...(reference.relativePath - ? [ - { - id: "copy-relative-path", - title: "Copy relative path", - run: () => copyTextWithHaptic(reference.relativePath!), - }, - ] - : []), - ...(reference.relativePath && source && "environmentId" in source - ? [ - { - id: "open-file", - title: "Open in file viewer", - run: () => { - onOpenFile?.(); - navigation.navigate("ThreadFile", { - environmentId: String(source.environmentId), - threadId: String(source.threadId), - path: reference.relativePath!.split("/"), - }); - }, - }, - ] - : []), - ] - : reference - ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const threadId = source && "threadId" in source ? source.threadId : undefined; + const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = + source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-full-path" as const, + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ] : []), - { - id: "share", - title: sharing ? "Opening share sheet…" : "Save or share", - run: share, - disabled: sharing, - }, - ] - : []; + ...(relativePath + ? [ + { + id: "copy-relative-path" as const, + title: "Copy relative path", + run: () => copyTextWithHaptic(relativePath), + }, + ] + : []), + ...(reference?.kind === "url" + ? [ + { + id: "copy-url" as const, + title: "Copy URL", + run: () => copyTextWithHaptic(reference.url), + }, + ] + : []), + ...(relativePath && "environmentId" in source && threadId !== undefined + ? [ + { + id: "open-file" as const, + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(threadId), + path: relativePath.split("/"), + }); + }, + }, + ] + : []), + { + id: "save" as const, + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; return { title: reference?.kind === "file" ? reference.path : reference?.url, actions, diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index af87a8d0f73ac4869ff5209e1ce3e17beffcb342..90db612d002fb1bcb121550378e0e318eea616ae 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -1,4 +1,5 @@ import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; import type { DraftComposerFileAttachment } from "./composerImages"; import type { MediaActionsSource } from "./mediaActions"; @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { | { readonly uri: string } | { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; } ); @@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string return JSON.stringify( "uri" in source ? ["media-video", source.uri] - : [ - "media-video", - source.environmentId, - source.resource.threadId, - source.resource.path, - source.srcFragment ?? "", - ], + : source.resource._tag === "attachment" + ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], ); } -export type AttachmentVideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export interface LocalVideoPreviewSource { + readonly type: "local"; + readonly attachment: DraftComposerFileAttachment; + readonly sourceIdentifier?: string; +} -export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; +export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource; + +export function attachmentVideoPreviewSource( + environmentId: EnvironmentId, + attachment: ChatFileAttachment, + sourceIdentifier?: string, +): MediaVideoPreviewSource { + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const resource = { + _tag: "attachment" as const, + attachmentId: attachment.id, + fileName: attachment.name, + mimeType, + }; + return { + type: "media", + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + actionsSource: { + name: attachment.name, + mimeType, + ...(sourceIdentifier ? { sourceIdentifier } : {}), + environmentId, + resource, + }, + }; +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 9e3e43c7cdcb393a7ac6097581a7ad18a671b46a..af6300d8ffa6d620a1f613126f05e4fea76b8d74 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,24 +1,21 @@ import { useAtomValue } from "@effect/atom-react"; -import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + createAssetEnvironmentAtoms, + EMPTY_ASSET_URL_ATOM, +} from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; +export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; + export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); -const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( - Atom.withLabel("mobile-asset-url:empty"), -); - -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; - export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, @@ -29,14 +26,10 @@ export function useAssetUrlState( ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); } export function useAssetUrl( @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - const result = await createUrl({ environmentId, input: { resource } }); - return result._tag === "Success" - ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) - : null; + const state = assetUrlStateFromResult( + await createUrl({ environmentId, input: { resource } }), + httpBaseUrl, + ); + return state._tag === "Success" ? state.url : null; }, [createUrl, environmentId, httpBaseUrl, resource]); } diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 5c642471404a6ae0ed642637fb8355490e0c6f6c..84ff979e4e894c7e9600ea5fb9fcbababf18d9e0 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,5 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; -import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + type AssetUrlState, + assetUrlStateFromResult, + EMPTY_ASSET_URL_ATOM, + resolveAssetUrl, +} from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; - -export type AssetUrlState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; +export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; export function useAssetUrlState( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( - assetEnvironment.createUrl({ - environmentId, - input: { resource }, - }), + environmentId === null || resource === null + ? EMPTY_ASSET_URL_ATOM + : assetEnvironment.createUrl({ environmentId, input: { resource } }), + ); + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, ); - if (result._tag === "Failure") { - return { _tag: "Failure" }; - } - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return { _tag: "Loading" }; - } - const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null - ? { _tag: "Failure" } - : { - _tag: "Success", - url, - ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), - }; } -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { const result = useAssetUrlState(environmentId, resource); - if (result._tag !== "Success") { - return null; - } - return result.url; + return result._tag === "Success" ? result.url : null; } -/** Re-mints an exact-file capability after a file change or an explicit retry. */ export function useAssetUrlRefresh( - environmentId: EnvironmentId, - resource: AssetResource, + environmentId: EnvironmentId | null, + resource: AssetResource | null, ): () => Promise { const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); return useCallback(async () => { + if (environmentId === null || resource === null) return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b08377e36a212b36872ef0b4f312c115983258c2..9cd6accbfba355dfcf3daa22c9c84bf24379599a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: { readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; readonly onRetry?: (() => Promise) | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { return ( { - props.onImageExpand?.({ - images: [ - { - src, - name: props.alt || "video", - type: "video", - autoPlay: false, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - ...(props.actionsSource - ? { actionsSource: { ...props.actionsSource, src } } - : {}), - }, - ], - index: 0, - }); - } - : undefined - } /> ); } @@ -1378,7 +1356,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props style={props.style} mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} onRetry={refreshAssetUrl} - onImageExpand={props.onImageExpand} actionsSource={actionsSource} /> ); @@ -2642,7 +2619,6 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} originalUrl={originalUrl} style={authoredSizeStyle} - onImageExpand={imageExpand} actionsSource={actionsSource} /> ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9fe03c1c980f37ae84de7c27fd9ab928588e693f..6be48b94ed374da4214146fcb550d042cfd9bc0a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,7 +24,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -130,14 +129,6 @@ describe("proactive panels", () => { }); }); -describe("isVideoPreviewRequestCurrent", () => { - it("rejects changed threads and replaced previews", () => { - expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); - expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); - }); -}); - describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0873787063387c4834b53722dec197082ef35236..cccc0a8dfe881698de7913ff1b49e89d3f9f7447 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { return url; } -export function isVideoPreviewRequestCurrent( - requestThreadKey: string, - currentThreadKey: string, - requestId: number, - currentRequestId: number, -): boolean { - return requestThreadKey === currentThreadKey && requestId === currentRequestId; -} - export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c08fb2b9ee3c0dfb3b4495013a4b8031c69422d7..e7f1a23bbc47f2945524900ff3a1af9449256dfa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,7 +135,6 @@ import { type ChatMessage, isBrowserPreviewAttachment, isImageAttachment, - videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { @@ -375,7 +374,6 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, @@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) { [composerRef], ); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); - const routeThreadKeyRef = useRef(routeThreadKey); - routeThreadKeyRef.current = routeThreadKey; - const videoPreviewRequestIdRef = useRef(0); - const cancelVideoPreviewRequest = useCallback(() => { - videoPreviewRequestIdRef.current += 1; - }, []); - const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; - if (item?.type !== "video" || !item.src.startsWith("blob:")) return; - return () => revokeBlobPreviewUrl(item.src); + if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; + const src = item.src; + return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); - cancelVideoPreviewRequest(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; - }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); + }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const isVideo = videoMimeType(attachment) !== null; - const action = isVideo ? "play" : "download"; - const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; - const isCurrentRequest = () => - !isVideo || - isVideoPreviewRequestCurrent( - routeThreadKey, - routeThreadKeyRef.current, - videoPreviewRequestId, - videoPreviewRequestIdRef.current, - ); - if (isVideo) setOpeningVideoAttachmentId(attachment.id); try { const url = await resolveFileAttachmentUrl({ @@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) { httpBaseUrl: connection.httpBaseUrl, createAssetUrl: createAttachmentAssetUrl, }); - if (!isCurrentRequest()) return; - if (isVideo) { - setExpandedImage({ - images: [{ src: url, name: attachment.name, type: "video" }], - index: 0, - }); - return; - } const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.name; anchor.click(); } catch (error) { - if (!isCurrentRequest()) return; toastManager.add({ type: "error", - title: "Could not " + action + " " + attachment.name, + title: "Could not download " + attachment.name, description: error instanceof Error ? error.message : "The attachment is unavailable.", }); - } finally { - if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } }, - [createAttachmentAssetUrl, environmentId, routeThreadKey], + [createAttachmentAssetUrl, environmentId], ); const openFileAttachment = useCallback( (attachment: ChatFileAttachment) => { @@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) { return []; }); resetLocalDispatch(); - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); setExpandedImage(null); - }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback( - (preview: ExpandedImagePreview) => { - cancelVideoPreviewRequest(); - setOpeningVideoAttachmentId(null); - setExpandedImage(preview); - }, - [cancelVideoPreviewRequest], - ); + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + setExpandedImage(preview); + }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; @@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) { onImageExpand={onExpandTimelineImage} onFileOpen={openFileAttachment} onFileDownload={downloadFileAttachment} - openingVideoAttachmentId={openingVideoAttachmentId} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} @@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) { setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} onFileOpen={openFileAttachment} - openingVideoAttachmentId={openingVideoAttachmentId} /> @@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) { {expandedImage && ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a1cd0fb4ae407e46125686fdce680e3048472b6..349faeac0728dfbd6c502acc37a2ce9d8ce922d5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -741,7 +741,6 @@ export interface ChatComposerProps { setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; } // -------------------------------------------------------------------------- @@ -820,7 +819,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, onFileOpen, - openingVideoAttachmentId, } = props; const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; @@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) maxFileAttachmentBytes !== null && file.sizeBytes <= maxFileAttachmentBytes; const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; - const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId; return (
{upload?.status === "uploading" && ( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 2ee40c0b8b637ec2d77c8894d50f0a2a4a00a915..5ab287225ca41fdf5dbdadeadbdd742bb0c2876f 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -2,11 +2,12 @@ import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; -import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; import { OpenMediaLink } from "../media/OpenMediaLink"; import { MediaActions, type MediaActionSource } from "../media/MediaActions"; +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { isContextMenuOpen } from "../../contextMenuFallback"; interface ExpandedImageDialogProps { @@ -14,23 +15,47 @@ interface ExpandedImageDialogProps { onClose: () => void; } +const EXPANDED_MEDIA_STATE_CLASS_NAME = + "flex aspect-auto h-48 min-h-0 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl"; + function ExpandedMediaFailure({ children }: { children: ReactNode }) { return ( -
+
{children}
); } +function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { + const asset = item.actionsSource?.asset; + const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); + const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); + const src = asset + ? assetUrl._tag === "Success" + ? assetUrl.url + (item.srcFragment ?? "") + : null + : item.src; + return ( + + ); +} + export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); - const [failedVideoSrc, setFailedVideoSrc] = useState(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const item = preview.images[index]; @@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ > - {item.type === "video" && failedVideoSrc === item.src ? ( - -

This video could not be loaded or played.

- -
- ) : item.type === "video" ? ( -
"); }); it("renders an ordinary file download button without creating its URL in advance", () => { const entry = { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 26a2881322ba9f0d6230edf90725e6b419ba14f2..ecfe33660e004f5658d781898c7e608deaddbc4d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,5 @@ import { type AssistantCitation, - type ChatFileAttachment, type EnvironmentId, type MessageId, type ScopedThreadRef, @@ -50,6 +49,7 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { + type ChatFileAttachment, type ChatImageAttachment, isBrowserPreviewAttachment, isFileAttachment, @@ -79,7 +79,6 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - PlayIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -89,8 +88,14 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; +import { + buildAttachmentVideoAsset, + buildExpandedImagePreview, + ExpandedImagePreview, +} from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; @@ -188,7 +193,6 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; @@ -289,7 +293,6 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; onFileDownload?: (attachment: ChatFileAttachment) => void; - openingVideoAttachmentId: string | null; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, onFileDownload = NOOP_OPEN_ATTACHMENT, - openingVideoAttachmentId, activeThreadEnvironmentId, markdownCwd, resolvedTheme, @@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen, onFileDownload, - openingVideoAttachmentId, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileOpen, onFileDownload, - openingVideoAttachmentId, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -1127,6 +1127,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { + const ctx = use(TimelineRowCtx); + const asset = useMemo( + () => + file.downloadable === false + ? null + : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file), + [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name], + ); + const resource = asset?.resource ?? null; + const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); + const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); + + if (asset === null && src === null) { + return ( +
+ {file.name} +
+ ); + } + + return ( + + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type @@ -1165,12 +1204,12 @@ function UserTimelineRow({ row }: { row: Extract (
{image.previewUrl ? ( ) : ( @@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {userVideos.map((file) => { - const isOpening = ctx.openingVideoAttachmentId === file.id; - return ( -
- -
- ); - })} + {userVideos.map((file) => ( + + ))}
)} {previewAnnotations.map((annotation, index) => ( diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index a67cadbca5a127f29d95be23d2c9b3a300e47dd0..cf79c81b9f60089da38aaa0fd943122c27fbcdbf 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -1,3 +1,4 @@ +import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import { mediaReferenceFileName, type MediaReference, @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { return { save, copyImage }; } -type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; - /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ export function MediaActions({ source, @@ -82,8 +81,6 @@ export function MediaActions({ const [tooltipOpen, setTooltipOpen] = useState(false); const menuOpen = useRef(false); const reference = source.reference; - const hasActions = - source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); const showMenu = async (position: { x: number; y: number }) => { @@ -94,28 +91,37 @@ export function MediaActions({ let failureTitle = "Could not open media menu"; let progressToast: ReturnType | undefined; try { - const items: ContextMenuItem[] = []; + const noun = source.kind === "image" ? "image" : "video"; + const unavailable = source.src === null && source.asset === undefined; + const canCopyImage = + typeof navigator !== "undefined" && + Boolean(navigator.clipboard?.write) && + typeof ClipboardItem !== "undefined"; + const items: ContextMenuItem[] = []; if (reference?.kind === "file") { - items.push({ id: "copy-full", label: "Copy full path" }); + items.push({ id: "copy-full-path", label: "Copy full path" }); if (reference.relativePath) - items.push({ id: "copy-relative", label: "Copy relative path" }); + items.push({ id: "copy-relative-path", label: "Copy relative path" }); } else if (reference?.kind === "url") { items.push({ id: "copy-url", label: "Copy URL" }); } - if (source.kind === "image") { - const unavailable = source.src === null && source.asset === undefined; - items.push({ id: "save", label: "Save image", disabled: unavailable }); - items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); - } if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); + if (source.kind === "image") { + items.push({ + id: "copy-image", + label: "Copy image", + disabled: unavailable || !canCopyImage, + }); + } const action = await api.contextMenu.show(items, position); if (!action) return; failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; const text = - action === "copy-full" && reference?.kind === "file" + action === "copy-full-path" && reference?.kind === "file" ? reference.path - : action === "copy-relative" && reference?.kind === "file" + : action === "copy-relative-path" && reference?.kind === "file" ? reference.relativePath : action === "copy-url" && reference?.kind === "url" ? reference.url @@ -131,7 +137,7 @@ export function MediaActions({ } else if (action === "save" || action === "copy-image") { progressToast = toastManager.add({ type: "loading", - title: action === "save" ? "Preparing image download…" : "Copying image…", + title: action === "save" ? `Preparing ${noun} download…` : "Copying image…", }); await (action === "save" ? save() : copyImage()); toastManager.update(progressToast, { @@ -158,7 +164,7 @@ export function MediaActions({ render={children} tabIndex={0} onContextMenu={(event) => { - if (!hasActions || event.defaultPrevented) return; + if (event.defaultPrevented) return; event.preventDefault(); event.stopPropagation(); const bounds = event.currentTarget.getBoundingClientRect(); @@ -170,7 +176,6 @@ export function MediaActions({ }} onKeyDown={(event) => { if ( - !hasActions || event.defaultPrevented || !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) ) diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 8f2d75680c14a75d1be42e88cc308b923dad1f45..f436beeb3855734abc9c4c4d87a3f13fdfde5f81 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -1,4 +1,4 @@ -import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils"; @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; + readonly autoPlay?: boolean | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; + /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ + readonly stateClassName?: string | undefined; readonly style?: CSSProperties | undefined; readonly copyMarkdown?: string | undefined; - readonly onExpand?: ((src: string) => void) | undefined; readonly onRetry?: (() => Promise) | undefined; readonly actionsSource?: MediaActionSource | undefined; } @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ originalUrl, revision = null, preload = "visible", + autoPlay = false, className, videoClassName, + stateClassName, style, copyMarkdown, - onExpand, onRetry, actionsSource, }: MediaVideoPlayerProps) { @@ -114,23 +117,6 @@ export function MediaVideoPlayer({ } }; - const expandButton = - onExpand && src !== null ? ( - - ) : null; - const player = ( @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ ) : null} - {expandButton} ) : src !== null ? ( @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ ref={videoRef} src={src} aria-label={label || "Video preview"} + autoPlay={autoPlay} controls playsInline preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ )} - {!failed && expandButton} ); return actionsSource ? {player} : player; diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5a6e2a02e10084822bc81f9d0bf555317395fab4..fc197a4092b5ef0d07e9698c3d5c1fcbe6071c6e 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,22 +1,18 @@ -import { splitPathAndPosition } from "./terminal-links"; +import { + fileBasename, + formatFilePathPosition, + splitFilePathPosition, + stripSlashPrefixedWindowsDrive, +} from "@t3tools/client-runtime/markdown-links"; function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); } -function canonicalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; -} - function trimTrailingPathSeparators(path: string): string { return path.replace(/[\\/]+$/, ""); } -function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; -} - function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } @@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath( pathWithPosition: string, workspaceRoot: string | undefined, ): string { - const { path, line, column } = splitPathAndPosition(pathWithPosition); - const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + const position = splitFilePathPosition(pathWithPosition); + const normalizedPath = stripSlashPrefixedWindowsDrive(normalizePathSeparators(position.path)); let displayPath = normalizedPath; if (workspaceRoot) { - const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( + const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); - const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); + const workspaceLabel = fileBasename(normalizedWorkspaceRoot); const pathForCompare = normalizedPath.toLowerCase(); const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); const workspaceWithSeparator = `${workspaceForCompare}/`; @@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath( } } - if (!line) return displayPath; - return `${displayPath}:${line}${column ? `:${column}` : ""}`; + return formatFilePathPosition({ ...position, path: displayPath }); } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 3b3bad7b25e3b62af62e621c1ee80707c3b8f3eb..e3b1cc5b2249ac53d10e3555596ebf0e8dcca4af 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => { ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); }); - it("does not treat app routes as file links", () => { + it("does not treat app routes as file links, even with a line anchor", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); + expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull(); + }); + + it("decodes an encoded drive colon in a file uri before dropping its slash", () => { + expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( + "c:/Users/x/shot.png", + ); }); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d55c9e1cd16c2bb1d28dabfde595e3f410cb70b2..8ed59eb8775b6306a5f75ee2ea93d29de9c84fd1 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,53 +1,21 @@ import { + fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, - isConventionalFilePosition, + isRelativeFilePath, + normalizeMarkdownLinkDestination, + parseFileUrlHref, + parseMarkdownFileLink, + safeDecodeURIComponent, + splitFilePathPosition, + workspaceRelativeFilePath, } from "@t3tools/client-runtime/markdown-links"; import { formatWorkspaceRelativePath } from "./filePathDisplay"; -import { - isTerminalLinkActivation, - resolvePathLinkTarget, - splitPathAndPosition, -} from "./terminal-links"; +import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; + +export { normalizeMarkdownLinkDestination }; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; -const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; -const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; -const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; -// Standard OS and dev-container roots; deliberately excludes app-route-ish -// prefixes like /app/ or /chat/ so SPA routes never read as files. -const POSIX_FILE_ROOT_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/etc/", - "/opt/", - "/mnt/", - "/Volumes/", - "/private/", - "/root/", - "/usr/", - "/bin/", - "/sbin/", - "/lib/", - "/lib64/", - "/srv/", - "/dev/", - "/proc/", - "/sys/", - "/run/", - "/boot/", - "/media/", - "/workspace/", - "/workspaces/", -] as const; const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; @@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - export function isWindowsDrivePathHref(href: string): boolean { - return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); -} - -function unwrapMarkdownLinkDestination(value: string): string { - return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; -} - -export function normalizeMarkdownLinkDestination(value: string): string { - return unwrapMarkdownLinkDestination(value.trim()); -} - -function stripSearchAndHash(value: string): { path: string; hash: string } { - const hashIndex = value.indexOf("#"); - const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; - const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : ""; - const queryIndex = pathWithSearch.indexOf("?"); - const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch; - return { path, hash: rawHash }; -} - -function normalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; -} - -function parseFileUrlHref( - href: string, - options?: { readonly decodePath?: boolean }, -): { path: string; hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") return null; - - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const rawPath = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - if (rawPath.length === 0) return null; - - // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. - const normalizedPath = normalizeWindowsDrivePath(rawPath); - - return { - path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), - hash: parsed.hash, - }; - } catch { - return null; - } + return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); } export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; - const normalizedHref = normalizeMarkdownLinkDestination(href); - const target = parseFileUrlHref(normalizedHref, { decodePath: false }); - if (!target) return null; - return `${target.path}${target.hash}`; -} - -function looksLikePosixFilesystemPath(path: string): boolean { - if (!path.startsWith("/")) return false; - if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; - if (POSITION_SUFFIX_PATTERN.test(path)) return true; - const basename = path.slice(path.lastIndexOf("/") + 1); - return /\.[A-Za-z0-9_-]+$/.test(basename); -} - -function appendLineColumnFromHash(path: string, hash: string): string { - if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path; - const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i); - if (!match?.[1]) return path; - const line = match[1]; - const column = match[2]; - return `${path}:${line}${column ? `:${column}` : ""}`; -} - -function isLikelyPathCandidate(path: string): boolean { - if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; - if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; - if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); - return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); -} - -function isRelativePath(path: string): boolean { - return ( - RELATIVE_PATH_PREFIX_PATTERN.test(path) || - (!path.startsWith("/") && - !WINDOWS_DRIVE_PATH_PATTERN.test(path) && - !WINDOWS_UNC_PATH_PATTERN.test(path)) - ); -} - -function hasExternalScheme(path: string): boolean { - const match = path.match(EXTERNAL_SCHEME_PATTERN); - if (!match) return false; - const rest = match[2] ?? ""; - if (rest.startsWith("//")) return true; - return !POSITION_ONLY_PATTERN.test(rest); + const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); + return target ? `${target.path}${target.hash}` : null; } /** @@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget( baseDir: string | undefined = cwd, ): string | null { if (!href) return null; - const rawHref = normalizeMarkdownLinkDestination(href); - if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null; - - const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") - ? parseFileUrlHref(rawHref) - : null; - const source = fileUrlTarget ?? stripSearchAndHash(rawHref); - const decodedPath = normalizeWindowsDrivePath( - fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()), - ); - const decodedHash = safeDecode(source.hash.trim()); - - if (decodedPath.length === 0) return null; - if ( - !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) && - !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) && - hasExternalScheme(decodedPath) - ) { - return null; - } - - if (!isLikelyPathCandidate(decodedPath)) return null; - - const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash); - if (!isRelativePath(pathWithPosition)) { - return pathWithPosition; - } + const target = parseMarkdownFileLink(href); + if (!target) return null; + const pathWithPosition = formatFilePathPosition(target); + if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; if (!baseDir) return null; return resolvePathLinkTarget(pathWithPosition, baseDir); } @@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta( const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); - if (resolved) return resolved; - - // `Makefile:12` — conventional extensionless names fail the generic - // markdown-link candidate patterns, but here the :line suffix already - // marked the span as a file reference. - if (baseDir && isConventionalFilePosition(candidate)) { - return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); - } - return null; -} - -function basenameOfPath(path: string): string { - // A trailing separator is a valid way to write a directory, so trim it before - // taking the final segment. Without this the segment reads as empty and the - // chip renders with no label at all. - const trimmed = path.replace(/[/\\]+$/, "") || path; - const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; -} - -function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { - if (!workspaceRoot) return null; - const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); - const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( - /\/+$/, - "", - ); - const pathForCompare = normalizedPath.toLowerCase(); - const rootForCompare = normalizedRoot.toLowerCase(); - if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; - return normalizedPath.slice(normalizedRoot.length + 1); + return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); } export function resolveMarkdownFileLinkMeta( @@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta( } function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { - const { path, line, column } = splitPathAndPosition(targetPath); - const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; - const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; - const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; - const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; - + const { path, line, column } = splitFilePathPosition(targetPath); return { filePath: path, targetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), - workspaceRelativePath: workspaceRelativePath(path, cwd), - basename: basenameOfPath(path), - ...(lineNumber !== undefined ? { line: lineNumber } : {}), - ...(columnNumber !== undefined ? { column: columnNumber } : {}), + workspaceRelativePath: workspaceRelativeFilePath(path, cwd), + basename: fileBasename(path), + ...(line !== undefined ? { line } : {}), + ...(column !== undefined ? { column } : {}), }; } diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index be4258ba6b0f91a74da47728bcf94370481b5305..204d0a742a0534f229aa398e97bc8851120ddc2a 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -1,3 +1,8 @@ +import { + formatFilePathPosition, + splitFilePathPosition, +} from "@t3tools/client-runtime/markdown-links"; + import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; @@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined { return undefined; } -export function splitPathAndPosition(value: string): { - path: string; - line: string | undefined; - column: string | undefined; -} { - let path = value; - let column: string | undefined; - let line: string | undefined; - - const columnMatch = path.match(/:(\d+)$/); - if (!columnMatch?.[1]) { - return { path, line: undefined, column: undefined }; - } - - column = columnMatch[1]; - path = path.slice(0, -columnMatch[0].length); - - const lineMatch = path.match(/:(\d+)$/); - if (lineMatch?.[1]) { - line = lineMatch[1]; - path = path.slice(0, -lineMatch[0].length); - } else { - line = column; - column = undefined; - } - - return { path, line, column }; -} - export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); @@ -271,7 +247,8 @@ export function isTerminalLinkActivation( } export function resolvePathLinkTarget(rawPath: string, cwd: string): string { - const { path, line, column } = splitPathAndPosition(rawPath); + const position = splitFilePathPosition(rawPath); + const { path } = position; let resolvedPath = path; if (path.startsWith("~/")) { @@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { resolvedPath = joinPath(cwd, path, separator); } - if (!line) return resolvedPath; - return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; + return formatFilePathPosition({ ...position, path: resolvedPath }); } diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 61b97ab6a86f8b5526367d6425bcc0378df95e3e..12b0040d4bd2cec350fa9c54879802aac368a29b 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -86,10 +86,15 @@ composer thumbnails, and workspace image previews. The workspace PDF web preview Open PDF action for the native viewer. Android retains its image viewer and uses the system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. -Received videos open directly from their signed asset URL. AVKit handles buffering; -the client does not download the entire file or show a separate opening overlay before -presentation. The URL is captured once per preview so credential refresh does not -restart playback. Saving or sharing still downloads the original file. +Every full-screen video preview on iOS is AVKit, playing directly from the signed asset +URL: sent attachments, composer drafts, and file links that point at a video. Inline +markdown embeds and the workspace file screen play in place with the Expo Video player. +AVKit handles buffering; the client does not download the entire file or show a separate +opening overlay before presentation. +The URL is captured once per preview so credential refresh does not restart playback. +Media actions (copy path, open in file viewer, save or share) belong to the surface that +opened the video, a long-press on the thumbnail, so the player carries no +menu. AVKit has no retry; a failure alerts and closes, re-mints the URL in the background, and the next open plays from the fresh one. The native presentation promise completes after dismissal. Local draft previews hold their file lease until that promise settles. The iOS preview component requests @@ -97,8 +102,10 @@ native dismissal when its source screen unmounts. Playback pauses in the backgro AVPlayer activates audio as playback starts. The presenter pauses and releases its own player on close, then restores the previous audio-session configuration if no other component changed it during playback. It does not deactivate the -shared session, which may still serve another player or recorder. Android retains -its React Native modal and Expo Video player. +shared session, which may still serve another player or recorder. Android plays every +video, attachments included, in its React Native modal with the Expo Video player. +Composer drafts play and share from their local file; other videos stream from the +signed URL and download only for save or share. `shareFileFromSource` uses the same source registration to anchor UIKit's activity controller. Its promise completes when the native share flow finishes, keeping diff --git a/docs/user/composer.md b/docs/user/composer.md index 13b4527ad261ffda64dde73bdba29747ce7b8bf7..681ad46514118ca8e7d285dd589784dd12db7ffa 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -37,12 +37,12 @@ download button beside it to save a copy. Other attached files download when sel Select a video attachment before or after sending to play it. Web and desktop use the browser's built-in controls. On mobile, videos open in a full-screen player with native playback controls. Supported videos show a thumbnail in the conversation and composer. -On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs -depend on the browser or device; you can save an unsupported video to open it in another app. +Received videos stream from their environment as they play on every platform. Supported formats and +codecs depend on the browser or device; you can save an unsupported video to open it in another app. On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the -conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open -the system share options. On Android, use **Save or share video** inside the preview. +conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open +the system share options. On Android, the same menu is also available inside the preview. On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach again** next to it. Attach the file again or remove it, then send. @@ -97,20 +97,20 @@ open a media preview. Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They stream from the environment as needed, rather than downloading the entire video before playback. Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; -video embeds show a player with controls and an option to expand. Visible video previews load +video embeds show a player with the browser's controls, full screen included. Visible video previews load an initial frame when supported, but stay paused until you press Play. Video file references use a filmstrip icon. On web and desktop, hover over a preview to see its full file path or original URL. Right-click -to copy that reference, save an image, or copy an image to the clipboard. Use the video player's -built-in controls to download videos. If the player cannot decode a video, its error message +to copy that reference, save the image or video, or copy an image to the clipboard. The video +player's built-in controls can download a video too. If the player cannot decode a video, its error message offers a link to open the source in the browser. Workspace media also offers **Copy relative path** and **Open in file viewer**. These actions are available in expanded previews too. -On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its -source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file -viewer from the same menu. Saving downloads a copy only when you request it; it does not change -how the video buffers during playback. On iOS, touch and hold a file reference in a message to +On mobile, touch and hold an inline image or a video thumbnail to see its source, +copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer +from the same menu. Saving downloads a copy only when you request it; it does not change how +the video buffers during playback. On iOS, touch and hold a file reference in a message to copy its full or relative path or open it in the file viewer. Use Markdown image syntax to embed either kind of media: diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 2f5292e84ef232af019e50706e099d2ae975c10f..ffd21de8f0b1eefa714479ed62af6bc1b5d8b0b0 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -27,6 +27,14 @@ "types": "./src/mediaReference.ts", "default": "./src/mediaReference.ts" }, + "./media-source": { + "types": "./src/mediaSource.ts", + "default": "./src/mediaSource.ts" + }, + "./media-actions": { + "types": "./src/mediaActions.ts", + "default": "./src/mediaActions.ts" + }, "./codex-file-citations": { "types": "./src/codexFileCitations.ts", "default": "./src/codexFileCitations.ts" diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 6f6fa31dbffa914ae52264932b3852b6d41f5d88..f4092674c5ae4447f14d911525a801bab664377d 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -1,66 +1,27 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import { + normalizeMarkdownLinkDestination, + parseFileUrlHref, + safeDecodeURIComponent, + splitMarkdownLinkSearchAndHash, + stripSlashPrefixedWindowsDrive, +} from "./markdownLinks.ts"; + const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; export type MarkdownImageSource = | { readonly _tag: "Direct"; readonly uri: string } | { readonly _tag: "WorkspaceFile"; readonly path: string } | { readonly _tag: "Blocked" }; -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function normalizeSource(value: string): string { - const trimmed = value.trim(); - return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; -} - export function markdownImageSourceFragment(source: string): string { - const normalizedSource = normalizeSource(source); - const hashIndex = normalizedSource.indexOf("#"); - return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; -} - -function normalizeWindowsDrivePath(value: string): string { - return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; -} - -function parseFileUrl(value: string): string | null { - try { - const parsed = new URL(value); - if (parsed.protocol.toLowerCase() !== "file:") return null; - - if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") { - const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\"); - return `\\\\${safeDecode(parsed.hostname)}${pathname}`; - } - - const pathname = safeDecode(parsed.pathname); - if (pathname.length === 0) return null; - return normalizeWindowsDrivePath(pathname); - } catch { - return null; - } -} - -function stripSearchAndHash(value: string): string { - const searchIndex = value.indexOf("?"); - const hashIndex = value.indexOf("#"); - const end = [searchIndex, hashIndex] - .filter((index) => index >= 0) - .reduce((lowest, index) => Math.min(lowest, index), value.length); - return value.slice(0, end); + return splitMarkdownLinkSearchAndHash(normalizeMarkdownLinkDestination(source)).hash; } function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { - const windows = - WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); - const separator = windows ? "\\" : "/"; + const separator = isWindowsAbsolutePath(workspaceRoot) ? "\\" : "/"; const root = workspaceRoot.replace(/[\\/]+$/, ""); const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); return `${root}${separator}${path}`; @@ -77,7 +38,7 @@ export function classifyMarkdownImageSource( ): MarkdownImageSource { if (value === null || value === undefined) return { _tag: "Blocked" }; - const source = normalizeSource(value); + const source = normalizeMarkdownLinkDestination(value); if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { return { _tag: "Blocked" }; } @@ -86,13 +47,20 @@ export function classifyMarkdownImageSource( } if (/^file:/i.test(source)) { - const path = parseFileUrl(source); - return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; + const target = parseFileUrlHref(source); + return target === null + ? { _tag: "Blocked" } + : { + _tag: "WorkspaceFile", + path: stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)), + }; } - const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source))); + const path = stripSlashPrefixedWindowsDrive( + safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), + ); if (path.length === 0) return { _tag: "Blocked" }; - if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) { + if (path.startsWith("/") || isWindowsAbsolutePath(path)) { return { _tag: "WorkspaceFile", path }; } if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 5763fc5b6ff404ec00f6428e70428d56deb02420..cf8f9ae2d16e7fdce504dbfa7bb8e8dcb29f1683 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js"; +import { + fileBasename, + inlineCodeFilePathCandidate, + isConventionalFilePosition, + parseFileUrlHref, + parseMarkdownFileLink, + splitFilePathPosition, + workspaceRelativeFilePath, +} from "./markdownLinks.ts"; describe("inlineCodeFilePathCandidate", () => { it.each([ @@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => { expect(isConventionalFilePosition("port:3000")).toBe(false); }); }); + +describe("parseFileUrlHref", () => { + it.each([ + ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], + [ + "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", + "D:/Programme/t3code/OpenInPicker.tsx", + "#L69", + ], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], + ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], + ])("parses %s", (href, path, hash) => { + expect(parseFileUrlHref(href)).toEqual({ path, hash }); + }); + + it("keeps percent-encoding so the caller decodes once", () => { + expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( + "/Users/julius/project/file%2520name.md", + ); + expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); + }); + + it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { + expect(parseFileUrlHref(href)).toBeNull(); + }); +}); + +describe("splitFilePathPosition", () => { + it.each([ + ["src/main.ts", "", { path: "src/main.ts" }], + ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], + ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], + ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], + ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], + ["src/main.ts:0", "", { path: "src/main.ts" }], + ["src/main.ts", "#section", { path: "src/main.ts" }], + ])("splits %s%s", (path, hash, expected) => { + expect(splitFilePathPosition(path, hash)).toEqual(expected); + }); +}); + +describe("parseMarkdownFileLink", () => { + // Both clients consume this table, so a path the web app recognizes is one + // the mobile app recognizes too. + it.each([ + ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], + ["/home/me/notes.md", "/home/me/notes.md"], + ["/usr/local/bin/tool", "/usr/local/bin/tool"], + ["/workspace/Makefile", "/workspace/Makefile"], + ["/tmp/favicons/", "/tmp/favicons/"], + ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], + ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], + ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], + ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], + ["", "D:/Programme/t3code/ChatMarkdown.tsx"], + ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], + ["file://localhost/home/me/notes.md", "/home/me/notes.md"], + ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], + ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], + ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], + ["./scripts/deploy", "./scripts/deploy"], + ["~/notes/today.md", "~/notes/today.md"], + ["AGENTS.md", "AGENTS.md"], + ["script.ts:10", "script.ts"], + ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], + ])("recognizes %s as a file", (href, path) => { + expect(parseMarkdownFileLink(href)?.path).toBe(path); + }); + + it.each([ + "", + "#anchor", + "//cdn.example.com/clip.mp4", + "https://example.com/docs", + "mailto:someone@example.com", + "javascript:alert(1)", + "/chat/settings", + "/chat/settings#L3", + "/app#L1", + "readme", + "TODO:12", + ])("does not treat %s as a file", (href) => { + expect(parseMarkdownFileLink(href)).toBeNull(); + }); + + it("accepts conventional extensionless names with or without a position", () => { + expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); + expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); + expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); + }); + + it("reads positions from suffixes and line anchors", () => { + expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ + path: "/Users/julius/project/src/main.ts", + line: 42, + column: 7, + }); + expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ + path: "\\\\server\\share\\src\\main.ts", + line: 42, + column: 7, + }); + }); +}); + +describe("fileBasename", () => { + it.each([ + ["/tmp/favicons/", "favicons"], + ["C:\\Users\\kelchm\\.claude\\", ".claude"], + ["/tmp/", "tmp"], + ["AGENTS.md", "AGENTS.md"], + ["/", "/"], + ])("labels %s as %s", (path, basename) => { + expect(fileBasename(path)).toBe(basename); + }); +}); + +describe("workspaceRelativeFilePath", () => { + it.each([ + ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], + ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], + ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"], + ["/tmp/report.ts", "/repo/project", null], + ["/repo/project-two/a.ts", "/repo/project", null], + ["/repo/project/a.ts", undefined, null], + ])("relates %s to %s", (path, workspaceRoot, relativePath) => { + expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); + }); +}); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 73048357e61615e74fc0ea4855d2df08c3429461..81cce1fd7eee8821e9e3fde60e07cd02f014a2f9 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -1,12 +1,49 @@ -const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; -const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; +const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; +const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// Standard OS and dev-container roots; deliberately excludes app-route-ish +// prefixes like /app/ or /chat/ so SPA routes never read as files. +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/srv/", + "/dev/", + "/proc/", + "/sys/", + "/run/", + "/boot/", + "/media/", + "/workspace/", + "/workspaces/", +] as const; // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. const EXTENSIONLESS_FILE_NAMES = new Set([ "Makefile", @@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { const trimmed = codeText.trim(); if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; - const candidate = - WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) - ? trimmed - : trimmed.replaceAll("\\", "/"); + const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/"); const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; const hasExplicitPathShape = RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || candidate.startsWith("/") || - WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || - WINDOWS_UNC_PATH_PATTERN.test(candidate); + isWindowsAbsolutePath(candidate); if (!hasExplicitPathShape) { const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; @@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { } return candidate; } + +export function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function normalizeMarkdownLinkDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ +export function stripSlashPrefixedWindowsDrive(path: string): string { + return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; +} + +export function splitMarkdownLinkSearchAndHash(value: string): { + readonly path: string; + readonly hash: string; +} { + const hashIndex = value.indexOf("#"); + const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; + const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; + const queryIndex = pathWithSearch.indexOf("?"); + return { + path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, + hash, + }; +} + +/** + * Turns a `file:` URL into a host path, still percent-encoded so callers that + * decode every destination in one place do not decode file URLs twice. A + * non-localhost authority becomes a UNC share. + */ +export function parseFileUrlHref( + href: string, +): { readonly path: string; readonly hash: string } | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") return null; + + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const path = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` + : parsed.pathname; + if (path.length === 0) return null; + return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; + } catch { + return null; + } +} + +export interface FilePathPosition { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { + const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); + const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); + if (!match?.[1]) return { path }; + + const line = Number.parseInt(match[1], 10); + const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); + return { + path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, + ...(line > 0 ? { line } : {}), + ...(column !== undefined && column > 0 ? { column } : {}), + }; +} + +export function formatFilePathPosition(position: FilePathPosition): string { + if (!position.line) return position.path; + return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; +} + +export function isRelativeFilePath(path: string): boolean { + return ( + RELATIVE_PATH_PREFIX_PATTERN.test(path) || + (!path.startsWith("/") && !isWindowsAbsolutePath(path)) + ); +} + +function looksLikePosixFilesystemPath(path: string): boolean { + if (!path.startsWith("/")) return false; + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; + if (POSITION_SUFFIX_PATTERN.test(path)) return true; + const basename = path.slice(path.lastIndexOf("/") + 1); + return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename); +} + +/** + * Decides whether a decoded link destination is a file path rather than a route + * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` + * anchor never turns `/chat/settings` into a file. + */ +function looksLikeFilePath(path: string, authoredPath: string): boolean { + if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; + if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); + if (EXTENSIONLESS_FILE_NAMES.has(path)) return true; + return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path); +} + +function hasExternalScheme(path: string): boolean { + if (isWindowsAbsolutePath(path)) return false; + const match = path.match(EXTERNAL_SCHEME_PATTERN); + if (!match) return false; + const rest = match[2] ?? ""; + if (rest.startsWith("//")) return true; + return !POSITION_ONLY_PATTERN.test(rest); +} + +export function parseMarkdownFileLink(href: string): FilePathPosition | null { + const normalized = normalizeMarkdownLinkDestination(href); + if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) { + return null; + } + + const source = + (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ?? + splitMarkdownLinkSearchAndHash(normalized); + // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded. + const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim())); + const hash = safeDecodeURIComponent(source.hash.trim()); + if (path.length === 0 || hasExternalScheme(path)) return null; + + const position = splitFilePathPosition(path, hash); + return looksLikeFilePath(position.path, path) ? position : null; +} + +export function fileBasename(path: string): string { + // A trailing separator is a valid way to write a directory. Trim it before + // taking the final segment so the label is never empty. + const trimmed = path.replace(/[/\\]+$/, ""); + if (trimmed.length === 0) return path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; +} + +export function workspaceRelativeFilePath( + path: string, + workspaceRoot: string | null | undefined, +): string | null { + if (!workspaceRoot) return null; + const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); + const normalizedRoot = stripSlashPrefixedWindowsDrive( + workspaceRoot.replaceAll("\\", "/"), + ).replace(/\/+$/, ""); + if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null; + return normalizedPath.slice(normalizedRoot.length + 1); +} diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0256dff93fef06578a32d210c68a2d637fa2450 --- /dev/null +++ b/packages/client-runtime/src/mediaActions.ts @@ -0,0 +1,8 @@ +/** Menu action ids shared by every client so labels and handlers line up across surfaces. */ +export type MediaActionId = + | "copy-full-path" + | "copy-relative-path" + | "copy-url" + | "open-file" + | "save" + | "copy-image"; diff --git a/packages/client-runtime/src/mediaReference.ts b/packages/client-runtime/src/mediaReference.ts index dcb93e6bc57e9392acb4c0a9b0d2c3bc4eac3441..f44ec90cd8799cc132180bde7150e86a64b9a1b2 100644 --- a/packages/client-runtime/src/mediaReference.ts +++ b/packages/client-runtime/src/mediaReference.ts @@ -1,5 +1,7 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { safeDecodeURIComponent } from "./markdownLinks.ts"; + /** The authored media location, never the temporary URL used to load its bytes. */ export type MediaReference = | { @@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde } catch { return undefined; } - if (!basename) return undefined; - try { - return decodeURIComponent(basename); - } catch { - return basename; - } + return basename ? safeDecodeURIComponent(basename) : undefined; } diff --git a/packages/client-runtime/src/mediaSource.test.ts b/packages/client-runtime/src/mediaSource.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9db7c5fee42801e7224ed3ffcf9fe5b56c2cc5f5 --- /dev/null +++ b/packages/client-runtime/src/mediaSource.test.ts @@ -0,0 +1,141 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMediaSource } from "./mediaSource.ts"; + +const threadId = ThreadId.make("thread-1"); +const attachmentId = + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222-mp4"; + +describe("resolveMediaSource", () => { + describe("direct URLs", () => { + it("keeps the authored URL and decodes the display name once", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMediaSource(href, { threadId })).toEqual({ + kind: "video", + mimeType: "video/mp4", + name: "clip one%20.mp4", + reference: { kind: "url", url: href }, + srcFragment: "#t=2", + access: "direct", + uri: href, + }); + }); + + it("keeps protocol-relative URLs as authored; clients add the scheme", () => { + const href = "//cdn.example.com/clip.mp4?sig=1#t=2"; + expect(resolveMediaSource(href, { threadId })).toMatchObject({ + access: "direct", + uri: href, + reference: { kind: "url", url: href }, + }); + }); + + it("accepts extensionless image embeds only when asked", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMediaSource(href, { threadId })).toBeNull(); + expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ + kind: "image", + mimeType: "image/*", + name: "render", + }); + }); + + it.each(["data:image/png;base64,AAAA", "blob:https://app.t3.codes/id"])( + "loads %s directly", + (href) => { + expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ + access: "direct", + uri: href, + }); + }, + ); + }); + + describe("host paths", () => { + // POSIX, Windows, UNC, and file URLs must all reach the same media-file resource. + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["C:\\Users\\demo\\frame.png", "C:\\Users\\demo\\frame.png"], + ["/C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], + ["file:///C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("resolves %s through the environment", (href, path) => { + expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toEqual({ + kind: "image", + mimeType: "image/png", + name: path.split(/[\\/]/).at(-1), + reference: { kind: "file", path }, + srcFragment: "", + access: "environment", + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("joins relative paths to the workspace and records the relative reference", () => { + expect( + resolveMediaSource("screens/logo.svg?v=2#mark", { threadId, workspaceRoot: "/repo" }), + ).toEqual({ + kind: "image", + mimeType: "image/svg+xml", + name: "logo.svg", + reference: { + kind: "file", + path: "/repo/screens/logo.svg", + relativePath: "screens/logo.svg", + }, + srcFragment: "#mark", + access: "environment", + resource: { _tag: "media-file", threadId, path: "/repo/screens/logo.svg" }, + }); + }); + + it("prefers a caller-resolved path over classifying the source", () => { + expect( + resolveMediaSource("logo.svg", { + threadId, + workspaceRoot: "/repo", + resolvedFilePath: "/repo/docs/logo.svg", + }), + ).toMatchObject({ + access: "environment", + resource: { _tag: "media-file", path: "/repo/docs/logo.svg" }, + reference: { relativePath: "docs/logo.svg" }, + }); + }); + + it("separates a playback fragment from literal filename characters", () => { + expect(resolveMediaSource("/tmp/clip%23one.mp4#t=2", { threadId })).toMatchObject({ + kind: "video", + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + }); + }); + + it("cannot be loaded without a thread to mint through", () => { + expect(resolveMediaSource("/tmp/clip.mp4", { threadId: undefined })).toMatchObject({ + kind: "video", + access: "unavailable", + }); + }); + + it.each(["notes.txt", "/repo/README", "/repo/archive.zip"])( + "returns null for the non-media path %s", + (href) => { + expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toBeNull(); + }, + ); + }); + + it("serves T3 attachment files in place like any other host path", () => { + const path = `/home/demo/.t3/userdata/attachments/${attachmentId}.mp4`; + expect(resolveMediaSource(path, { threadId, workspaceRoot: "/repo" })).toMatchObject({ + kind: "video", + access: "environment", + resource: { _tag: "media-file", threadId, path }, + }); + }); +}); diff --git a/packages/client-runtime/src/mediaSource.ts b/packages/client-runtime/src/mediaSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c285e21ba993dc9cee2ab4d1b9bccbda3900cdb --- /dev/null +++ b/packages/client-runtime/src/mediaSource.ts @@ -0,0 +1,104 @@ +import type { AssetResource, ThreadId } from "@t3tools/contracts"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; + +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, + type MarkdownImageSource, +} from "./markdownImages.ts"; +import { + fileBasename, + splitFilePathPosition, + splitMarkdownLinkSearchAndHash, +} from "./markdownLinks.ts"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, + type MediaReference, +} from "./mediaReference.ts"; + +export type MediaSourceResource = Extract; + +/** What a piece of authored media is and how its bytes can be reached. */ +export type ResolvedMediaSource = { + readonly kind: "image" | "video"; + readonly mimeType: string; + readonly name: string; + readonly reference?: MediaReference; + readonly srcFragment: string; +} & ( + | { + readonly access: "direct"; + readonly uri: string; + } + | { + readonly access: "environment"; + readonly resource: MediaSourceResource; + } + | { + readonly access: "unavailable"; + } +); + +export interface ResolveMediaSourceInput { + readonly threadId: ThreadId | undefined; + readonly workspaceRoot?: string | null | undefined; + readonly resolvedFilePath?: string | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean | undefined; +} + +function classify(source: string, input: ResolveMediaSourceInput): MarkdownImageSource { + return input.resolvedFilePath === undefined + ? classifyMarkdownImageSource(source, input.workspaceRoot) + : { _tag: "WorkspaceFile", path: input.resolvedFilePath }; +} + +export function resolveMediaSource( + source: string, + input: ResolveMediaSourceInput, +): ResolvedMediaSource | null { + const classified = classify(source, input); + if (classified._tag === "Blocked") return null; + + const path = + classified._tag === "Direct" + ? splitMarkdownLinkSearchAndHash(classified.uri).path + : splitFilePathPosition(classified.path).path; + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const basename = fileBasename(path); + const extensionIndex = basename.lastIndexOf("."); + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = (reference && mediaReferenceFileName(reference)) || basename || kind; + const common = { + kind, + mimeType, + name, + ...(reference ? { reference } : {}), + srcFragment: markdownImageSourceFragment(source), + } as const; + + if (classified._tag === "Direct") { + return { ...common, access: "direct", uri: classified.uri }; + } + if (input.threadId === undefined) return { ...common, access: "unavailable" }; + return { + ...common, + access: "environment", + resource: { _tag: "media-file", threadId: input.threadId, path }, + }; +} diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028f5e44d76144399ae27d55a889e3b2..f2f82def3dd79ad647fa5bfd7aa156a54b772ff6 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,6 +1,11 @@ -import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + type AssetCreateUrlResult, + AssetResource, + EnvironmentId, + WS_METHODS, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; @@ -43,6 +48,35 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin } } +export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("asset-url:empty"), +); + +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { + readonly _tag: "Success"; + readonly url: string; + /** The host path the server chose to serve, when it differs from what was asked for. */ + readonly sourcePath?: string; + }; + +export function assetUrlStateFromResult( + result: AsyncResult.AsyncResult, + httpBaseUrl: string | null, +): AssetUrlState { + if (result._tag === "Failure") return { _tag: "Failure" }; + if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" }; + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) return { _tag: "Failure" }; + return { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index be590287a30467a974f2c48df83b42e4a29d9f5b..deb4d68cf06705603b4dc0c43e2b370f0429dd9c 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -353,17 +353,11 @@ describe("toolGroupAction", () => { describe("resolveViewedImageAsset", () => { const threadId = ThreadId.make("thread-1"); - it("loads t3 attachment paths as attachments", () => { - const attachmentId = - "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; - expect( - resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { - threadId, - workspaceRoot: "/workspace", - }), - ).toEqual({ - resource: { _tag: "attachment", attachmentId }, - alt: `${attachmentId}.png`, + it("serves t3 attachment paths in place like any other host path", () => { + const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png"; + expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({ + resource: { _tag: "media-file", threadId, path }, + alt: "11111111-1111-4111-8111-111111111111.png", srcFragment: "", }); }); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 4b9a20cf0406bab7beb1c0dce9eaec2ea9fee43a..ae208a5bb49a883db0d3af09b39ffb643fd24a4b 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -4,10 +4,8 @@ import { type ThreadId, type ToolLifecycleItemType, } from "@t3tools/contracts"; -import { - classifyMarkdownImageSource, - markdownImageSourceFragment, -} from "@t3tools/client-runtime/markdown-images"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; export function isWorktreeSetupActivity(kind: string): boolean { @@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin } export interface ViewedImageAsset { - readonly resource: Extract; + readonly resource: Extract; readonly alt: string; readonly srcFragment: string; } -const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; -const T3_ATTACHMENT_IMAGE_PATH_PATTERN = - /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; - export function resolveViewedImageAsset( source: string, input: { @@ -366,24 +360,22 @@ export function resolveViewedImageAsset( readonly workspaceRoot?: string | null | undefined; }, ): ViewedImageAsset | null { + // A relative path with no known workspace still names a media-file relative + // to the thread's workspace, so classify against "." and drop the prefix. const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); if (imageSource._tag !== "WorkspaceFile") return null; - - const path = + const resolvedFilePath = input.workspaceRoot == null && imageSource.path.startsWith("./") ? imageSource.path.slice(2) : imageSource.path; - const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) - ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) - : null; - return { - resource: attachmentId - ? { _tag: "attachment", attachmentId } - : { _tag: "media-file", threadId: input.threadId, path }, - alt: path.split(/[\\/]/).at(-1) ?? "image", - srcFragment: markdownImageSourceFragment(source), - }; + const media = resolveMediaSource(source, { + threadId: input.threadId, + workspaceRoot: input.workspaceRoot, + resolvedFilePath, + }); + if (media === null || media.access !== "environment") return null; + return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment }; } function toolGroupActionCount( -- 2.54.0