| author | |
| committer | |
| log | 922bd692251bc803c12a3fab159efe83c957bb70 |
| tree | eb779ea33c073ce16ab93e59bddfea60768d12b2 |
| parent | 31eeb443305a4e11c8b20a7c8ff2b3f5e841eb0f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>52 files changed, 1582 insertions(+), 1740 deletions(-)
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+13-143| ... | @@ -1,31 +1,15 @@ | ... | @@ -1,31 +1,15 @@ |
| 1 | import { | 1 | import { |
| 2 | fileBasename, | ||
| 3 | formatFilePathPosition, | ||
| 2 | inlineCodeFilePathCandidate, | 4 | inlineCodeFilePathCandidate, |
| 3 | isConventionalFilePosition, | 5 | normalizeMarkdownLinkDestination, |
| 6 | parseMarkdownFileLink, | ||
| 4 | } from "@t3tools/client-runtime/markdown-links"; | 7 | } from "@t3tools/client-runtime/markdown-links"; |
| 5 | import { videoMimeType } from "@t3tools/shared/video"; | 8 | import { videoMimeType } from "@t3tools/shared/video"; |
| 6 | 9 | ||
| 7 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | 10 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; |
| 8 | 11 | ||
| 9 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | ||
| 10 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | ||
| 11 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | ||
| 12 | const RELATIVE_FILE_PATH_PATTERN = | ||
| 13 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; | ||
| 14 | const RELATIVE_FILE_NAME_PATTERN = | ||
| 15 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | ||
| 16 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 12 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 17 | const POSIX_FILE_ROOT_PREFIXES = [ | ||
| 18 | "/Users/", | ||
| 19 | "/home/", | ||
| 20 | "/tmp/", | ||
| 21 | "/var/", | ||
| 22 | "/etc/", | ||
| 23 | "/opt/", | ||
| 24 | "/mnt/", | ||
| 25 | "/Volumes/", | ||
| 26 | "/private/", | ||
| 27 | "/root/", | ||
| 28 | ] as const; | ||
| 29 | 13 | ||
| 30 | export type MarkdownLinkPresentation = | 14 | export type MarkdownLinkPresentation = |
| 31 | | { | 15 | | { |
| ... | @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { | ... | @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { |
| 246 | zsh: "bash", | 230 | zsh: "bash", |
| 247 | }; | 231 | }; |
| 248 | 232 | ||
| 249 | function safeDecode(value: string): string { | ||
| 250 | try { | ||
| 251 | return decodeURIComponent(value); | ||
| 252 | } catch { | ||
| 253 | return value; | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 257 | function normalizeDestination(value: string): string { | ||
| 258 | const trimmed = value.trim(); | ||
| 259 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | ||
| 260 | } | ||
| 261 | |||
| 262 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ | 233 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ |
| 263 | export function normalizeNativeMarkdownUrl(value: string): string { | 234 | export function normalizeNativeMarkdownUrl(value: string): string { |
| 264 | return value.startsWith("//") ? `https:${value}` : value; | 235 | return value.startsWith("//") ? `https:${value}` : value; |
| 265 | } | 236 | } |
| 266 | 237 | ||
| 267 | function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { | ||
| 268 | try { | ||
| 269 | const parsed = new URL(href); | ||
| 270 | if (parsed.protocol.toLowerCase() !== "file:") { | ||
| 271 | return null; | ||
| 272 | } | ||
| 273 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; | ||
| 274 | const rawPath = uncHostname | ||
| 275 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` | ||
| 276 | : parsed.pathname; | ||
| 277 | const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; | ||
| 278 | return { path, hash: parsed.hash }; | ||
| 279 | } catch { | ||
| 280 | return null; | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { | ||
| 285 | const hashIndex = value.indexOf("#"); | ||
| 286 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; | ||
| 287 | const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; | ||
| 288 | const queryIndex = pathWithSearch.indexOf("?"); | ||
| 289 | return { | ||
| 290 | path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, | ||
| 291 | hash, | ||
| 292 | }; | ||
| 293 | } | ||
| 294 | |||
| 295 | function splitFilePosition( | ||
| 296 | path: string, | ||
| 297 | hash: string, | ||
| 298 | ): { readonly path: string; readonly line?: number; readonly column?: number } { | ||
| 299 | const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); | ||
| 300 | const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); | ||
| 301 | const match = suffixMatch ?? hashMatch; | ||
| 302 | if (!match?.[1]) { | ||
| 303 | return { path }; | ||
| 304 | } | ||
| 305 | |||
| 306 | const line = Number.parseInt(match[1], 10); | ||
| 307 | const column = match[2] ? Number.parseInt(match[2], 10) : undefined; | ||
| 308 | const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; | ||
| 309 | return { | ||
| 310 | path: pathWithoutPosition, | ||
| 311 | ...(line > 0 ? { line } : {}), | ||
| 312 | ...(column !== undefined && column > 0 ? { column } : {}), | ||
| 313 | }; | ||
| 314 | } | ||
| 315 | |||
| 316 | function looksLikePosixFilesystemPath(path: string): boolean { | ||
| 317 | if (!path.startsWith("/")) { | ||
| 318 | return false; | ||
| 319 | } | ||
| 320 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { | ||
| 321 | return true; | ||
| 322 | } | ||
| 323 | if (POSITION_SUFFIX_PATTERN.test(path)) { | ||
| 324 | return true; | ||
| 325 | } | ||
| 326 | const basename = path.slice(path.lastIndexOf("/") + 1); | ||
| 327 | return /\.[A-Za-z0-9_-]+$/.test(basename); | ||
| 328 | } | ||
| 329 | |||
| 330 | function looksLikeFilePath(value: string): boolean { | ||
| 331 | if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { | ||
| 332 | return true; | ||
| 333 | } | ||
| 334 | if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { | ||
| 335 | return true; | ||
| 336 | } | ||
| 337 | if (value.startsWith("/")) { | ||
| 338 | return looksLikePosixFilesystemPath(value); | ||
| 339 | } | ||
| 340 | if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { | ||
| 341 | return true; | ||
| 342 | } | ||
| 343 | if (isConventionalFilePosition(value)) return true; | ||
| 344 | return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); | ||
| 345 | } | ||
| 346 | |||
| 347 | function fileLabel(value: string): string { | ||
| 348 | const normalized = value.replaceAll("\\", "/"); | ||
| 349 | const basename = normalized.slice(normalized.lastIndexOf("/") + 1); | ||
| 350 | return basename || normalized; | ||
| 351 | } | ||
| 352 | |||
| 353 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | 238 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 354 | const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); | 239 | const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); |
| 355 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; | 240 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; |
| 356 | const exactIcon = FILE_ICON_BY_NAME[basename]; | 241 | const exactIcon = FILE_ICON_BY_NAME[basename]; |
| 357 | if (exactIcon) return exactIcon; | 242 | if (exactIcon) return exactIcon; |
| ... | @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | ... | @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 367 | } | 252 | } |
| 368 | 253 | ||
| 369 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { | 254 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { |
| 370 | const normalized = normalizeDestination(href); | 255 | const normalized = normalizeMarkdownLinkDestination(href); |
| 371 | try { | 256 | try { |
| 372 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); | 257 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); |
| 373 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { | 258 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { |
| ... | @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese | ... | @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 381 | // Relative paths and non-URL link destinations are handled below. | 266 | // Relative paths and non-URL link destinations are handled below. |
| 382 | } | 267 | } |
| 383 | 268 | ||
| 384 | const source = normalized.toLowerCase().startsWith("file:") | 269 | const target = parseMarkdownFileLink(normalized); |
| 385 | ? fileUrlTarget(normalized) | 270 | if (target) { |
| 386 | : stripSearchAndHash(normalized); | ||
| 387 | const decodedSource = source | ||
| 388 | ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } | ||
| 389 | : null; | ||
| 390 | const fileTarget = decodedSource | ||
| 391 | ? splitFilePosition(decodedSource.path, decodedSource.hash) | ||
| 392 | : null; | ||
| 393 | const targetWithPosition = fileTarget | ||
| 394 | ? `${fileTarget.path}${ | ||
| 395 | fileTarget.line | ||
| 396 | ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` | ||
| 397 | : "" | ||
| 398 | }` | ||
| 399 | : null; | ||
| 400 | if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { | ||
| 401 | return { | 271 | return { |
| 402 | kind: "file", | 272 | kind: "file", |
| 403 | href: normalized, | 273 | href: normalized, |
| 404 | icon: resolveMarkdownFileIcon(fileTarget.path), | 274 | icon: resolveMarkdownFileIcon(target.path), |
| 405 | label: fileLabel(targetWithPosition), | 275 | label: fileBasename(formatFilePathPosition(target)), |
| 406 | path: fileTarget.path, | 276 | path: target.path, |
| 407 | ...(fileTarget.line ? { line: fileTarget.line } : {}), | 277 | ...(target.line ? { line: target.line } : {}), |
| 408 | ...(fileTarget.column ? { column: fileTarget.column } : {}), | 278 | ...(target.column ? { column: target.column } : {}), |
| 409 | }; | 279 | }; |
| 410 | } | 280 | } |
| 411 | 281 |
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift+2| ... | @@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, | ... | @@ -144,6 +144,8 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, |
| 144 | type = detectedType | 144 | type = detectedType |
| 145 | } else if CGPDFDocument(download as CFURL) != nil { | 145 | } else if CGPDFDocument(download as CFURL) != nil { |
| 146 | type = .pdf | 146 | type = .pdf |
| 147 | } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { | ||
| 148 | type = .svg | ||
| 147 | } else { | 149 | } else { |
| 148 | throw URLError(.cannotDecodeContentData) | 150 | throw URLError(.cannotDecodeContentData) |
| 149 | } | 151 | } |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+12-43| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | import { SymbolView } from "../components/AppSymbol"; | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useEffect, useRef, useState } from "react"; | 3 | import { useMemo } from "react"; |
| 4 | import { Alert, Image, Pressable, ScrollView, View } from "react-native"; | 4 | import { Image, Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | ||
| 6 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; | 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 9 | import type { MediaActionsSource } from "../lib/mediaActions"; |
| 10 | import { PresentationSource } from "./NativePresentation"; | 10 | import { PresentationSource } from "./NativePresentation"; |
| 11 | import type { FilePreviewSource } from "./FilePreviewModal"; | 11 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 12 | import { isPdfFile } from "../lib/filePreview"; | 12 | import { isPdfFile } from "../lib/filePreview"; |
| ... | @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { | ... | @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { |
| 175 | const { attachment } = props; | 175 | const { attachment } = props; |
| 176 | const sourceIdentifier = `draft:${attachment.id}`; | 176 | const sourceIdentifier = `draft:${attachment.id}`; |
| 177 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | 177 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 178 | const shareRef = useRef<AbortController | null>(null); | 178 | const actionsSource = useMemo<MediaActionsSource>( |
| 179 | const [sharing, setSharing] = useState(false); | 179 | () => ({ |
| 180 | useEffect( | 180 | name: attachment.name, |
| 181 | () => () => { | 181 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, |
| 182 | shareRef.current?.abort(); | 182 | sourceIdentifier, |
| 183 | shareRef.current = null; | 183 | attachment, |
| 184 | }, | 184 | }), |
| 185 | [], | 185 | [attachment, sourceIdentifier], |
| 186 | ); | 186 | ); |
| 187 | 187 | ||
| 188 | const onShare = () => { | ||
| 189 | if (shareRef.current) return; | ||
| 190 | const controller = new AbortController(); | ||
| 191 | shareRef.current = controller; | ||
| 192 | setSharing(true); | ||
| 193 | void (async () => { | ||
| 194 | const preview = await loadLocalAttachmentPreview(attachment, controller.signal); | ||
| 195 | if (!preview) return; | ||
| 196 | try { | ||
| 197 | await preview.share(controller.signal, sourceIdentifier); | ||
| 198 | } finally { | ||
| 199 | preview.dispose(); | ||
| 200 | } | ||
| 201 | })() | ||
| 202 | .catch((error: unknown) => { | ||
| 203 | if (!controller.signal.aborted) { | ||
| 204 | Alert.alert( | ||
| 205 | "Could not share video", | ||
| 206 | error instanceof Error ? error.message : "Try again.", | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | }) | ||
| 210 | .finally(() => { | ||
| 211 | if (shareRef.current === controller) { | ||
| 212 | shareRef.current = null; | ||
| 213 | setSharing(false); | ||
| 214 | } | ||
| 215 | }); | ||
| 216 | }; | ||
| 217 | |||
| 218 | return ( | 188 | return ( |
| 219 | <VideoAttachmentTile | 189 | <VideoAttachmentTile |
| 220 | name={attachment.name} | 190 | name={attachment.name} |
| ... | @@ -222,8 +192,7 @@ function ComposerVideoAttachment(props: { | ... | @@ -222,8 +192,7 @@ function ComposerVideoAttachment(props: { |
| 222 | thumbnailSource={attachment} | 192 | thumbnailSource={attachment} |
| 223 | compact={props.compact} | 193 | compact={props.compact} |
| 224 | onPress={() => props.onPressVideo(attachment, sourceIdentifier)} | 194 | onPress={() => props.onPressVideo(attachment, sourceIdentifier)} |
| 225 | onShare={onShare} | 195 | actionsSource={actionsSource} |
| 226 | disabled={sharing} | ||
| 227 | style={style} | 196 | style={style} |
| 228 | /> | 197 | /> |
| 229 | ); | 198 | ); |
apps/mobile/src/components/FilePreview.ios.tsx+1-6| ... | @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react"; | ... | @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useId } from "react"; |
| 3 | import { Alert } from "react-native"; | 3 | import { Alert } from "react-native"; |
| 4 | 4 | ||
| 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; |
| 6 | import { MediaImagePreview } from "./MediaImagePreview"; | ||
| 7 | 6 | ||
| 8 | const NativeControls = requireNativeModule<{ | 7 | const NativeControls = requireNativeModule<{ |
| 9 | presentFile( | 8 | presentFile( |
| ... | @@ -47,9 +46,5 @@ export function FilePreview(props: { | ... | @@ -47,9 +46,5 @@ export function FilePreview(props: { |
| 47 | readonly source: ResolvedFilePreviewSource; | 46 | readonly source: ResolvedFilePreviewSource; |
| 48 | readonly onRequestClose: () => void; | 47 | readonly onRequestClose: () => void; |
| 49 | }) { | 48 | }) { |
| 50 | return props.source.kind === "image" && props.source.actionsSource ? ( | 49 | return <NativeFilePreview {...props} />; |
| 51 | <MediaImagePreview {...props} /> | ||
| 52 | ) : ( | ||
| 53 | <NativeFilePreview {...props} /> | ||
| 54 | ); | ||
| 55 | } | 50 | } |
apps/mobile/src/components/MediaActionsMenu.tsx+3-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { MenuView } from "@react-native-menu/menu"; | 1 | import { MenuView } from "@react-native-menu/menu"; |
| 2 | import type { ReactElement } from "react"; | 2 | import type { ReactElement } from "react"; |
| 3 | import { Platform, View, type PressableProps } from "react-native"; | 3 | import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; |
| 4 | 4 | ||
| 5 | import type { useMediaActions } from "../lib/mediaActions"; | 5 | import type { useMediaActions } from "../lib/mediaActions"; |
| 6 | import { SymbolView } from "./AppSymbol"; | 6 | import { SymbolView } from "./AppSymbol"; |
| ... | @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { | ... | @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { |
| 10 | readonly media: ReturnType<typeof useMediaActions>; | 10 | readonly media: ReturnType<typeof useMediaActions>; |
| 11 | readonly inModal?: boolean; | 11 | readonly inModal?: boolean; |
| 12 | readonly children?: ReactElement<PressableProps>; | 12 | readonly children?: ReactElement<PressableProps>; |
| 13 | readonly style?: StyleProp<ViewStyle>; | ||
| 13 | }) { | 14 | }) { |
| 14 | if (props.media.actions.length === 0) return props.children ?? null; | 15 | if (props.media.actions.length === 0) return props.children ?? null; |
| 15 | // Android's normal anchored menu lives in the app-root portal, behind native modals. | 16 | // Android's normal anchored menu lives in the app-root portal, behind native modals. |
| ... | @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { | ... | @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { |
| 18 | return ( | 19 | return ( |
| 19 | <Menu | 20 | <Menu |
| 20 | title={props.media.title} | 21 | title={props.media.title} |
| 22 | style={props.style} | ||
| 21 | shouldOpenOnLongPress={props.children !== undefined} | 23 | shouldOpenOnLongPress={props.children !== undefined} |
| 22 | actions={props.media.actions.map(({ id, title, disabled }) => ({ | 24 | actions={props.media.actions.map(({ id, title, disabled }) => ({ |
| 23 | id, | 25 | id, |
apps/mobile/src/components/MediaImagePreview.tsx+1-1| ... | @@ -42,7 +42,7 @@ function ImagePreviewHeader() { | ... | @@ -42,7 +42,7 @@ function ImagePreviewHeader() { |
| 42 | ); | 42 | ); |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | /** Chat and workspace media retain source actions on both platforms; other files use native previews. */ | 45 | /** Android keeps media actions in its in-app image viewer. iOS uses Quick Look. */ |
| 46 | export function MediaImagePreview(props: MediaImagePreviewProps) { | 46 | export function MediaImagePreview(props: MediaImagePreviewProps) { |
| 47 | return ( | 47 | return ( |
| 48 | <ImagePreviewContext value={props}> | 48 | <ImagePreviewContext value={props}> |
apps/mobile/src/components/MediaVideoPlayer.tsx+35-46| ... | @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { | ... | @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { |
| 101 | readonly thumbnailVisible?: boolean; | 101 | readonly thumbnailVisible?: boolean; |
| 102 | readonly unavailable?: boolean; | 102 | readonly unavailable?: boolean; |
| 103 | readonly expanded?: boolean; | 103 | readonly expanded?: boolean; |
| 104 | readonly autoPlay?: boolean; | ||
| 104 | readonly paused?: boolean; | 105 | readonly paused?: boolean; |
| 105 | readonly onExpand?: () => void; | ||
| 106 | readonly actionsSource?: MediaActionsSource; | 106 | readonly actionsSource?: MediaActionsSource; |
| 107 | } | 107 | } |
| 108 | 108 | ||
| ... | @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { | ... | @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { |
| 122 | <LoadedMediaVideo | 122 | <LoadedMediaVideo |
| 123 | uri={props.uri ?? playbackUri} | 123 | uri={props.uri ?? playbackUri} |
| 124 | resolvePlaybackUri={props.resolvePlaybackUri} | 124 | resolvePlaybackUri={props.resolvePlaybackUri} |
| 125 | playRequested={!props.expanded} | 125 | playRequested={!props.expanded || props.autoPlay === true} |
| 126 | paused={(props.paused ?? false) || mediaActions.sharing} | 126 | paused={(props.paused ?? false) || mediaActions.sharing} |
| 127 | /> | 127 | /> |
| 128 | ) : ( | 128 | ) : ( |
| 129 | <Pressable | 129 | <MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}> |
| 130 | accessibilityRole="button" | 130 | <Pressable |
| 131 | accessibilityLabel={`Play ${props.name}`} | 131 | accessibilityRole="button" |
| 132 | accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} | 132 | accessibilityLabel={`Play ${props.name}`} |
| 133 | disabled={props.uri === null || props.unavailable === true} | 133 | accessibilityHint={ |
| 134 | onPress={() => setPlaybackUri(props.uri)} | 134 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined |
| 135 | className="flex-1 items-center justify-center gap-2 px-4" | 135 | } |
| 136 | > | 136 | accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} |
| 137 | {!props.unavailable ? ( | 137 | // Stays pressable so the long-press menu still opens on a failed or unsigned tile. |
| 138 | <VideoThumbnailImage | 138 | onPress={() => { |
| 139 | cacheKey={props.thumbnailKey} | 139 | if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); |
| 140 | source={props.thumbnailVisible === false ? null : props.uri} | 140 | }} |
| 141 | contentFit="contain" | 141 | className="flex-1 items-center justify-center px-4" |
| 142 | /> | 142 | > |
| 143 | ) : null} | 143 | {!props.unavailable ? ( |
| 144 | {props.unavailable ? ( | 144 | <VideoThumbnailImage |
| 145 | <AppText className="text-sm text-white/80">Video unavailable</AppText> | 145 | cacheKey={props.thumbnailKey} |
| 146 | ) : props.uri === null ? ( | 146 | source={props.thumbnailVisible === false ? null : props.uri} |
| 147 | <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> | 147 | contentFit="contain" |
| 148 | ) : ( | 148 | /> |
| 149 | <> | 149 | ) : null} |
| 150 | {props.unavailable ? ( | ||
| 151 | <AppText className="text-sm text-white/80">Video unavailable</AppText> | ||
| 152 | ) : props.uri === null ? ( | ||
| 153 | <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> | ||
| 154 | ) : ( | ||
| 150 | <View className="size-12 items-center justify-center rounded-full bg-black/60"> | 155 | <View className="size-12 items-center justify-center rounded-full bg-black/60"> |
| 151 | <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" /> | 156 | <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" /> |
| 152 | </View> | 157 | </View> |
| 158 | )} | ||
| 159 | {props.uri !== null && !props.unavailable ? ( | ||
| 153 | <AppText | 160 | <AppText |
| 154 | className="rounded bg-black/60 px-2 py-1 text-center text-xs text-white" | 161 | className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white" |
| 155 | numberOfLines={2} | 162 | numberOfLines={1} |
| 156 | > | 163 | > |
| 157 | {props.name} | 164 | {props.name} |
| 158 | </AppText> | 165 | </AppText> |
| 159 | </> | 166 | ) : null} |
| 160 | )} | 167 | </Pressable> |
| 161 | </Pressable> | 168 | </MediaActionsMenu> |
| 162 | )} | 169 | )} |
| 163 | {props.onExpand ? ( | ||
| 164 | <Pressable | ||
| 165 | accessibilityRole="button" | ||
| 166 | accessibilityLabel={`Expand ${props.name}`} | ||
| 167 | onPress={() => { | ||
| 168 | setPlaybackUri(null); | ||
| 169 | props.onExpand?.(); | ||
| 170 | }} | ||
| 171 | className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" | ||
| 172 | > | ||
| 173 | <AppText className="text-xs text-white">Expand</AppText> | ||
| 174 | </Pressable> | ||
| 175 | ) : null} | ||
| 176 | {props.actionsSource ? ( | ||
| 177 | <View className="absolute left-1 top-1"> | ||
| 178 | <MediaActionsMenu media={mediaActions} inModal={props.expanded} /> | ||
| 179 | </View> | ||
| 180 | ) : null} | ||
| 181 | </View> | 170 | </View> |
| 182 | ); | 171 | ); |
| 183 | } | 172 | } |
apps/mobile/src/components/MediaVideoPreviewModal.tsx deleted-96| ... | @@ -1,96 +0,0 @@ | ||
| 1 | import { useEffect } from "react"; | ||
| 2 | import { Keyboard, Modal, Pressable, View } from "react-native"; | ||
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 4 | |||
| 5 | import { useMediaActions } from "../lib/mediaActions"; | ||
| 6 | import { MediaActionsMenu } from "./MediaActionsMenu"; | ||
| 7 | import { | ||
| 8 | mediaVideoPreviewUri, | ||
| 9 | mediaVideoThumbnailKey, | ||
| 10 | type MediaVideoPreviewSource, | ||
| 11 | } from "../lib/videoPreviewSource"; | ||
| 12 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; | ||
| 13 | import { usePreparedConnection } from "../state/session"; | ||
| 14 | import { AppText } from "./AppText"; | ||
| 15 | import { SymbolView } from "./AppSymbol"; | ||
| 16 | import { MediaVideoPlayer } from "./MediaVideoPlayer"; | ||
| 17 | import { MediaSourceCaption } from "./MediaSourceCaption"; | ||
| 18 | |||
| 19 | /** Media files stream in place. A client-side copy is made only for an explicit share. */ | ||
| 20 | export function MediaVideoPreviewModal(props: { | ||
| 21 | readonly source: MediaVideoPreviewSource; | ||
| 22 | readonly onRequestClose: () => void; | ||
| 23 | }) { | ||
| 24 | const { source } = props; | ||
| 25 | const insets = useSafeAreaInsets(); | ||
| 26 | const environmentId = "environmentId" in source ? source.environmentId : null; | ||
| 27 | const connection = usePreparedConnection(environmentId); | ||
| 28 | const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); | ||
| 29 | const refreshAssetUrl = useRefreshAssetUrl( | ||
| 30 | environmentId, | ||
| 31 | "resource" in source ? source.resource : null, | ||
| 32 | ); | ||
| 33 | const resolvePlaybackUri = | ||
| 34 | "resource" in source | ||
| 35 | ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) | ||
| 36 | : undefined; | ||
| 37 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); | ||
| 38 | const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); | ||
| 39 | const unavailable = | ||
| 40 | uri === null && | ||
| 41 | environmentId !== null && | ||
| 42 | (connection._tag === "None" || asset._tag === "Failure"); | ||
| 43 | |||
| 44 | useEffect(() => Keyboard.dismiss(), []); | ||
| 45 | return ( | ||
| 46 | <Modal | ||
| 47 | visible | ||
| 48 | animationType="fade" | ||
| 49 | presentationStyle="fullScreen" | ||
| 50 | onRequestClose={props.onRequestClose} | ||
| 51 | statusBarTranslucent | ||
| 52 | navigationBarTranslucent | ||
| 53 | > | ||
| 54 | <View | ||
| 55 | className="flex-1 bg-black" | ||
| 56 | style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} | ||
| 57 | > | ||
| 58 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> | ||
| 59 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> | ||
| 60 | {source.name} | ||
| 61 | </AppText> | ||
| 62 | <MediaActionsMenu media={mediaActions} inModal /> | ||
| 63 | <Pressable | ||
| 64 | accessibilityRole="button" | ||
| 65 | accessibilityLabel="Close video" | ||
| 66 | onPress={props.onRequestClose} | ||
| 67 | className="size-12 items-center justify-center" | ||
| 68 | > | ||
| 69 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> | ||
| 70 | </Pressable> | ||
| 71 | </View> | ||
| 72 | <MediaSourceCaption source={mediaActions.title} /> | ||
| 73 | <MediaVideoPlayer | ||
| 74 | uri={uri} | ||
| 75 | resolvePlaybackUri={resolvePlaybackUri} | ||
| 76 | name={source.name} | ||
| 77 | thumbnailKey={mediaVideoThumbnailKey(source)} | ||
| 78 | unavailable={unavailable} | ||
| 79 | paused={mediaActions.sharing} | ||
| 80 | expanded | ||
| 81 | /> | ||
| 82 | <Pressable | ||
| 83 | accessibilityRole="button" | ||
| 84 | accessibilityLabel="Save or share video" | ||
| 85 | disabled={uri === null || mediaActions.sharing} | ||
| 86 | onPress={mediaActions.share} | ||
| 87 | className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4" | ||
| 88 | > | ||
| 89 | <AppText className="font-t3-medium text-base text-white"> | ||
| 90 | {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} | ||
| 91 | </AppText> | ||
| 92 | </Pressable> | ||
| 93 | </View> | ||
| 94 | </Modal> | ||
| 95 | ); | ||
| 96 | } | ||
apps/mobile/src/components/VideoAttachmentMenu.tsx deleted-53| ... | @@ -1,53 +0,0 @@ | ||
| 1 | import type { ReactElement } from "react"; | ||
| 2 | import { Platform, type PressableProps } from "react-native"; | ||
| 3 | |||
| 4 | import { ControlPillMenu } from "./ControlPill"; | ||
| 5 | import { PresentationSource } from "./NativePresentation"; | ||
| 6 | |||
| 7 | export function VideoAttachmentMenu(props: { | ||
| 8 | readonly sourceIdentifier: string; | ||
| 9 | readonly onOpen: () => void; | ||
| 10 | readonly onShare?: () => void; | ||
| 11 | readonly disabled?: boolean; | ||
| 12 | readonly children: ReactElement<PressableProps>; | ||
| 13 | }) { | ||
| 14 | return ( | ||
| 15 | <PresentationSource | ||
| 16 | identifier={props.sourceIdentifier} | ||
| 17 | accessible={Platform.OS === "ios"} | ||
| 18 | accessibilityRole="button" | ||
| 19 | accessibilityLabel={props.children.props.accessibilityLabel} | ||
| 20 | accessibilityHint={props.children.props.accessibilityHint} | ||
| 21 | accessibilityState={{ disabled: props.disabled ?? false }} | ||
| 22 | onAccessibilityTap={() => { | ||
| 23 | if (!props.disabled) props.onOpen(); | ||
| 24 | }} | ||
| 25 | accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} | ||
| 26 | onAccessibilityAction={({ nativeEvent }) => { | ||
| 27 | if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); | ||
| 28 | }} | ||
| 29 | > | ||
| 30 | {Platform.OS === "ios" && props.onShare ? ( | ||
| 31 | <ControlPillMenu | ||
| 32 | shouldOpenOnLongPress | ||
| 33 | style={{ alignSelf: "flex-start" }} | ||
| 34 | actions={[ | ||
| 35 | { | ||
| 36 | id: "share", | ||
| 37 | title: "Save or share video", | ||
| 38 | image: "square.and.arrow.up", | ||
| 39 | attributes: { disabled: props.disabled ?? false }, | ||
| 40 | }, | ||
| 41 | ]} | ||
| 42 | onPressAction={({ nativeEvent }) => { | ||
| 43 | if (nativeEvent.event === "share") props.onShare?.(); | ||
| 44 | }} | ||
| 45 | > | ||
| 46 | {props.children} | ||
| 47 | </ControlPillMenu> | ||
| 48 | ) : ( | ||
| 49 | props.children | ||
| 50 | )} | ||
| 51 | </PresentationSource> | ||
| 52 | ); | ||
| 53 | } | ||
apps/mobile/src/components/VideoAttachmentTile.tsx+56-42| ... | @@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react | ... | @@ -2,65 +2,79 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react |
| 2 | 2 | ||
| 3 | import { cn } from "../lib/cn"; | 3 | import { cn } from "../lib/cn"; |
| 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 5 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | ||
| 5 | import { SymbolView } from "./AppSymbol"; | 6 | import { SymbolView } from "./AppSymbol"; |
| 6 | import { AppText } from "./AppText"; | 7 | import { AppText } from "./AppText"; |
| 7 | import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; | 8 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
| 9 | import { PresentationSource } from "./NativePresentation"; | ||
| 8 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; | 10 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; |
| 9 | 11 | ||
| 10 | export function VideoAttachmentTile(props: { | 12 | export function VideoAttachmentTile(props: { |
| 11 | readonly name: string; | 13 | readonly name: string; |
| 12 | readonly sourceIdentifier: string; | 14 | readonly sourceIdentifier: string; |
| 13 | readonly thumbnailSource: string | DraftComposerFileAttachment | null; | 15 | readonly thumbnailSource: string | DraftComposerFileAttachment | null; |
| 16 | readonly actionsSource?: MediaActionsSource; | ||
| 14 | readonly compact?: boolean; | 17 | readonly compact?: boolean; |
| 15 | readonly onPress: (sourceIdentifier: string) => void; | 18 | readonly onPress: (sourceIdentifier: string) => void; |
| 16 | readonly onShare?: () => void; | ||
| 17 | readonly disabled?: boolean; | 19 | readonly disabled?: boolean; |
| 18 | readonly className?: string; | 20 | readonly className?: string; |
| 19 | readonly style?: StyleProp<ViewStyle>; | 21 | readonly style?: StyleProp<ViewStyle>; |
| 20 | }) { | 22 | }) { |
| 23 | const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource); | ||
| 24 | const hasActions = mediaActions.actions.length > 0; | ||
| 21 | return ( | 25 | return ( |
| 22 | <VideoAttachmentMenu | 26 | <PresentationSource |
| 23 | sourceIdentifier={props.sourceIdentifier} | 27 | identifier={props.sourceIdentifier} |
| 24 | onOpen={() => props.onPress(props.sourceIdentifier)} | 28 | accessible={Platform.OS === "ios"} |
| 25 | onShare={props.onShare} | 29 | accessibilityRole="button" |
| 26 | disabled={props.disabled} | 30 | accessibilityLabel={`Play ${props.name}`} |
| 31 | accessibilityState={{ disabled: props.disabled ?? false }} | ||
| 32 | onAccessibilityTap={() => { | ||
| 33 | if (!props.disabled) props.onPress(props.sourceIdentifier); | ||
| 34 | }} | ||
| 35 | accessibilityActions={mediaActions.actions.map(({ id, title }) => ({ | ||
| 36 | name: id, | ||
| 37 | label: title, | ||
| 38 | }))} | ||
| 39 | onAccessibilityAction={({ nativeEvent }) => { | ||
| 40 | if (props.disabled) return; | ||
| 41 | mediaActions.actions.find(({ id }) => id === nativeEvent.actionName)?.run(); | ||
| 42 | }} | ||
| 27 | > | 43 | > |
| 28 | <Pressable | 44 | <MediaActionsMenu media={mediaActions}> |
| 29 | accessibilityRole="button" | 45 | <Pressable |
| 30 | accessibilityLabel={`Play ${props.name}`} | 46 | accessibilityRole="button" |
| 31 | accessibilityHint={ | 47 | accessibilityLabel={`Play ${props.name}`} |
| 32 | Platform.OS === "ios" && props.onShare | 48 | accessibilityHint={hasActions ? "Touch and hold for media actions" : undefined} |
| 33 | ? "Touch and hold for save and share options" | 49 | accessibilityState={{ disabled: props.disabled ?? false }} |
| 34 | : undefined | 50 | disabled={props.disabled} |
| 35 | } | 51 | onPress={() => props.onPress(props.sourceIdentifier)} |
| 36 | accessibilityState={{ disabled: props.disabled ?? false }} | 52 | className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} |
| 37 | disabled={props.disabled} | 53 | style={props.style} |
| 38 | onPress={() => props.onPress(props.sourceIdentifier)} | ||
| 39 | className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} | ||
| 40 | style={props.style} | ||
| 41 | > | ||
| 42 | <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} /> | ||
| 43 | <View | ||
| 44 | className={cn( | ||
| 45 | "items-center justify-center rounded-full bg-black/45", | ||
| 46 | props.compact ? "size-6" : "size-12", | ||
| 47 | )} | ||
| 48 | > | 54 | > |
| 49 | <SymbolView | 55 | <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} /> |
| 50 | name="play" | 56 | <View |
| 51 | size={props.compact ? 15 : 24} | 57 | className={cn( |
| 52 | tintColor="#ffffff" | 58 | "items-center justify-center rounded-full bg-black/45", |
| 53 | type="monochrome" | 59 | props.compact ? "size-6" : "size-12", |
| 54 | /> | 60 | )} |
| 55 | </View> | 61 | > |
| 56 | {!props.compact ? ( | 62 | <SymbolView |
| 57 | <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5"> | 63 | name="play" |
| 58 | <AppText className="text-center text-xs text-white" numberOfLines={1}> | 64 | size={props.compact ? 15 : 24} |
| 59 | {props.name} | 65 | tintColor="#ffffff" |
| 60 | </AppText> | 66 | type="monochrome" |
| 67 | /> | ||
| 61 | </View> | 68 | </View> |
| 62 | ) : null} | 69 | {!props.compact ? ( |
| 63 | </Pressable> | 70 | <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5"> |
| 64 | </VideoAttachmentMenu> | 71 | <AppText className="text-center text-xs text-white" numberOfLines={1}> |
| 72 | {props.name} | ||
| 73 | </AppText> | ||
| 74 | </View> | ||
| 75 | ) : null} | ||
| 76 | </Pressable> | ||
| 77 | </MediaActionsMenu> | ||
| 78 | </PresentationSource> | ||
| 65 | ); | 79 | ); |
| 66 | } | 80 | } |
apps/mobile/src/components/VideoPreviewModal.ios.tsx+28-30| ... | @@ -1,14 +1,12 @@ | ... | @@ -1,14 +1,12 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 3 | import { requireNativeModule } from "expo"; | 2 | import { requireNativeModule } from "expo"; |
| 4 | import { useEffect, useEffectEvent, useId, useState } from "react"; | 3 | import { useEffect, useEffectEvent, useId, useState } from "react"; |
| 5 | import { Alert, Keyboard } from "react-native"; | 4 | import { Alert, Keyboard } from "react-native"; |
| 6 | 5 | ||
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 6 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import { useAssetUrlState } from "../state/assets"; | 7 | import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 8 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; | ||
| 9 | import { usePreparedConnection } from "../state/session"; | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | ||
| 11 | import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; | ||
| 12 | 10 | ||
| 13 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; | 11 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 14 | 12 | ||
| ... | @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ | ... | @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ |
| 23 | }>("T3NativeControls"); | 21 | }>("T3NativeControls"); |
| 24 | 22 | ||
| 25 | function NativeVideoPreview(props: { | 23 | function NativeVideoPreview(props: { |
| 26 | readonly source: AttachmentVideoPreviewSource; | 24 | readonly source: VideoPreviewSource; |
| 27 | readonly onRequestClose: () => void; | 25 | readonly onRequestClose: () => void; |
| 28 | }) { | 26 | }) { |
| 29 | const { source } = props; | 27 | const { source } = props; |
| 30 | const { attachment } = source; | 28 | const localAttachment = source.type === "local" ? source.attachment : null; |
| 31 | const identifier = useId(); | 29 | const identifier = useId(); |
| 32 | const onRequestClose = useEffectEvent(props.onRequestClose); | 30 | const onRequestClose = useEffectEvent(props.onRequestClose); |
| 33 | const environmentId = source.type === "remote" ? source.environmentId : null; | 31 | const environmentId = |
| 32 | source.type === "media" && "environmentId" in source ? source.environmentId : null; | ||
| 33 | const resource = source.type === "media" && "resource" in source ? source.resource : null; | ||
| 34 | const preparedConnection = usePreparedConnection(environmentId); | 34 | const preparedConnection = usePreparedConnection(environmentId); |
| 35 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | 35 | const assetUrl = useAssetUrlState(environmentId, resource); |
| 36 | const assetUrl = useAssetUrlState( | 36 | const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); |
| 37 | environmentId, | 37 | const name = source.type === "media" ? source.name : source.attachment.name; |
| 38 | source.type === "remote" | 38 | // The first minted URL is kept so a background refresh does not restart playback. |
| 39 | ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } | 39 | const resolvedUrl = |
| 40 | : null, | 40 | source.type === "media" |
| 41 | ); | 41 | ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) |
| 42 | const [playbackUrl, setPlaybackUrl] = useState<string | null>(() => | 42 | : null; |
| 43 | assetUrl._tag === "Success" ? assetUrl.url : null, | 43 | const [playbackUrl, setPlaybackUrl] = useState<string | null>(resolvedUrl); |
| 44 | ); | ||
| 45 | const loadError = | 44 | const loadError = |
| 46 | source.type === "remote" && playbackUrl === null | 45 | resource !== null && playbackUrl === null |
| 47 | ? preparedConnection._tag === "None" | 46 | ? preparedConnection._tag === "None" |
| 48 | ? "Reconnect to this environment and open the video again." | 47 | ? "Reconnect to this environment and open the video again." |
| 49 | : assetUrl._tag === "Failure" | 48 | : assetUrl._tag === "Failure" |
| ... | @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { | ... | @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { |
| 53 | 52 | ||
| 54 | useEffect(() => Keyboard.dismiss(), []); | 53 | useEffect(() => Keyboard.dismiss(), []); |
| 55 | useEffect(() => { | 54 | useEffect(() => { |
| 56 | if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); | 55 | if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); |
| 57 | }, [playbackUrl, assetUrl]); | 56 | }, [playbackUrl, resolvedUrl]); |
| 58 | useEffect(() => { | 57 | useEffect(() => { |
| 59 | if (!loadError) return; | 58 | if (!loadError) return; |
| 60 | Alert.alert("Could not open video", loadError); | 59 | Alert.alert("Could not open video", loadError); |
| ... | @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { | ... | @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { |
| 62 | }, [loadError]); | 61 | }, [loadError]); |
| 63 | 62 | ||
| 64 | useEffect(() => { | 63 | useEffect(() => { |
| 65 | if (source.type === "remote" && playbackUrl === null) return; | 64 | if (localAttachment === null && playbackUrl === null) return; |
| 66 | const controller = new AbortController(); | 65 | const controller = new AbortController(); |
| 67 | let ready = false; | 66 | let ready = false; |
| 68 | void (async () => { | 67 | void (async () => { |
| 69 | const file = | 68 | const file = |
| 70 | source.type === "local" | 69 | localAttachment !== null |
| 71 | ? await loadLocalAttachmentPreview(source.attachment, controller.signal) | 70 | ? await loadLocalAttachmentPreview(localAttachment, controller.signal) |
| 72 | : null; | 71 | : null; |
| 73 | if (source.type === "local" && !file) return; | 72 | if (localAttachment !== null && !file) return; |
| 74 | try { | 73 | try { |
| 75 | if (controller.signal.aborted) return; | 74 | if (controller.signal.aborted) return; |
| 76 | ready = true; | 75 | ready = true; |
| 77 | await NativeControls.presentVideo( | 76 | await NativeControls.presentVideo( |
| 78 | file?.uri ?? playbackUrl!, | 77 | file?.uri ?? playbackUrl!, |
| 79 | attachment.name, | 78 | name, |
| 80 | source.sourceIdentifier ?? "", | 79 | source.sourceIdentifier ?? "", |
| 81 | identifier, | 80 | identifier, |
| 82 | ); | 81 | ); |
| ... | @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { | ... | @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { |
| 87 | } | 86 | } |
| 88 | })().catch((error: unknown) => { | 87 | })().catch((error: unknown) => { |
| 89 | if (controller.signal.aborted) return; | 88 | if (controller.signal.aborted) return; |
| 89 | // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. | ||
| 90 | if (ready) void refreshAssetUrl(); | ||
| 90 | Alert.alert( | 91 | Alert.alert( |
| 91 | "Could not open video", | 92 | "Could not open video", |
| 92 | ready | 93 | ready |
| 93 | ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." | 94 | ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original." |
| 94 | : error instanceof Error | 95 | : error instanceof Error |
| 95 | ? error.message | 96 | ? error.message |
| 96 | : "Could not load this video.", | 97 | : "Could not load this video.", |
| ... | @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { | ... | @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { |
| 101 | controller.abort(); | 102 | controller.abort(); |
| 102 | void NativeControls.dismissVideo(identifier).catch(() => undefined); | 103 | void NativeControls.dismissVideo(identifier).catch(() => undefined); |
| 103 | }; | 104 | }; |
| 104 | }, [source, attachment.name, playbackUrl, identifier]); | 105 | }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); |
| 105 | 106 | ||
| 106 | return null; | 107 | return null; |
| 107 | } | 108 | } |
| ... | @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { | ... | @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { |
| 118 | }, [isFocused, hasSource]); | 119 | }, [isFocused, hasSource]); |
| 119 | 120 | ||
| 120 | if (!props.source || !isFocused) return null; | 121 | if (!props.source || !isFocused) return null; |
| 121 | if (props.source.type === "media") { | ||
| 122 | return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />; | ||
| 123 | } | ||
| 124 | return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />; | 122 | return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />; |
| 125 | } | 123 | } |
apps/mobile/src/components/VideoPreviewModal.tsx+153-180| ... | @@ -1,190 +1,139 @@ | ... | @@ -1,190 +1,139 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useEvent } from "expo"; | ||
| 4 | import { useVideoPlayer, VideoView } from "expo-video"; | ||
| 5 | import { useEffect, useRef, useState } from "react"; | 3 | import { useEffect, useRef, useState } from "react"; |
| 6 | import { | 4 | import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native"; |
| 7 | ActivityIndicator, | ||
| 8 | AppState, | ||
| 9 | Keyboard, | ||
| 10 | Modal, | ||
| 11 | Pressable, | ||
| 12 | StyleSheet, | ||
| 13 | View, | ||
| 14 | } from "react-native"; | ||
| 15 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 16 | 6 | ||
| 17 | import { | ||
| 18 | downloadAttachmentForPreview, | ||
| 19 | type AttachmentPreviewFile, | ||
| 20 | } from "../lib/attachmentDownload"; | ||
| 21 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 22 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | 8 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; |
| 23 | import { useAssetUrlState } from "../state/assets"; | 9 | import { |
| 10 | mediaVideoPreviewUri, | ||
| 11 | mediaVideoThumbnailKey, | ||
| 12 | type LocalVideoPreviewSource, | ||
| 13 | type MediaVideoPreviewSource, | ||
| 14 | type VideoPreviewSource, | ||
| 15 | } from "../lib/videoPreviewSource"; | ||
| 16 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; | ||
| 24 | import { usePreparedConnection } from "../state/session"; | 17 | import { usePreparedConnection } from "../state/session"; |
| 25 | import { SymbolView } from "./AppSymbol"; | ||
| 26 | import { AppText } from "./AppText"; | 18 | import { AppText } from "./AppText"; |
| 27 | import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; | 19 | import { SymbolView } from "./AppSymbol"; |
| 20 | import { MediaActionsMenu } from "./MediaActionsMenu"; | ||
| 21 | import { MediaSourceCaption } from "./MediaSourceCaption"; | ||
| 22 | import { MediaVideoPlayer } from "./MediaVideoPlayer"; | ||
| 28 | 23 | ||
| 29 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; | 24 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 30 | 25 | ||
| 31 | function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { | 26 | interface PlaybackState { |
| 32 | const player = useVideoPlayer(props.file.uri, (player) => { | 27 | readonly uri: string | null; |
| 33 | player.staysActiveInBackground = false; | 28 | readonly resolvePlaybackUri?: () => Promise<string | null>; |
| 34 | if (AppState.currentState === "active") player.play(); | 29 | readonly unavailable: boolean; |
| 35 | }); | 30 | readonly error: string | null; |
| 36 | const { status } = useEvent(player, "statusChange", { status: player.status }); | 31 | readonly actionsSource: MediaActionsSource | undefined; |
| 37 | const shareControllerRef = useRef<AbortController | null>(null); | 32 | } |
| 38 | const [sharing, setSharing] = useState(false); | ||
| 39 | const [shareError, setShareError] = useState<string | null>(null); | ||
| 40 | |||
| 41 | useEffect( | ||
| 42 | () => () => { | ||
| 43 | shareControllerRef.current?.abort(); | ||
| 44 | shareControllerRef.current = null; | ||
| 45 | }, | ||
| 46 | [], | ||
| 47 | ); | ||
| 48 | 33 | ||
| 49 | const onShare = () => { | 34 | function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { |
| 50 | if (shareControllerRef.current) return; | 35 | const environmentId = "environmentId" in source ? source.environmentId : null; |
| 51 | player.pause(); | 36 | const resource = "resource" in source ? source.resource : null; |
| 52 | const controller = new AbortController(); | 37 | const connection = usePreparedConnection(environmentId); |
| 53 | shareControllerRef.current = controller; | 38 | const asset = useAssetUrlState(environmentId, resource); |
| 54 | setSharing(true); | 39 | const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); |
| 55 | setShareError(null); | 40 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); |
| 56 | void props.file | 41 | return { |
| 57 | .share(controller.signal) | 42 | uri, |
| 58 | .catch((error: unknown) => { | 43 | ...(resource !== null |
| 59 | if (!controller.signal.aborted) { | 44 | ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } |
| 60 | setShareError(error instanceof Error ? error.message : "Could not share this video."); | 45 | : {}), |
| 61 | } | 46 | unavailable: |
| 62 | }) | 47 | uri === null && |
| 63 | .finally(() => { | 48 | environmentId !== null && |
| 64 | if (shareControllerRef.current === controller) { | 49 | (connection._tag === "None" || asset._tag === "Failure"), |
| 65 | shareControllerRef.current = null; | 50 | error: null, |
| 66 | setSharing(false); | 51 | actionsSource: source.actionsSource, |
| 67 | } | ||
| 68 | }); | ||
| 69 | }; | 52 | }; |
| 70 | |||
| 71 | return ( | ||
| 72 | <> | ||
| 73 | <View className="flex-1 items-center justify-center"> | ||
| 74 | {status === "error" ? ( | ||
| 75 | <AppText className="px-6 text-center text-base text-white/80"> | ||
| 76 | This video couldn't be played on this device. You can save or share the original file. | ||
| 77 | </AppText> | ||
| 78 | ) : ( | ||
| 79 | <> | ||
| 80 | <VideoView | ||
| 81 | player={player} | ||
| 82 | style={StyleSheet.absoluteFill} | ||
| 83 | nativeControls | ||
| 84 | contentFit="contain" | ||
| 85 | fullscreenOptions={{ enable: true }} | ||
| 86 | allowsPictureInPicture={false} | ||
| 87 | /> | ||
| 88 | {status === "loading" ? ( | ||
| 89 | <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> | ||
| 90 | ) : null} | ||
| 91 | </> | ||
| 92 | )} | ||
| 93 | </View> | ||
| 94 | <Pressable | ||
| 95 | accessibilityRole="button" | ||
| 96 | accessibilityLabel="Save or share video" | ||
| 97 | accessibilityState={{ disabled: sharing, busy: sharing }} | ||
| 98 | disabled={sharing} | ||
| 99 | onPress={onShare} | ||
| 100 | className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4" | ||
| 101 | > | ||
| 102 | <AppText className="font-t3-medium text-base text-white"> | ||
| 103 | {sharing ? "Opening share sheet..." : "Save or share video"} | ||
| 104 | </AppText> | ||
| 105 | </Pressable> | ||
| 106 | {shareError ? ( | ||
| 107 | <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80"> | ||
| 108 | {shareError} | ||
| 109 | </AppText> | ||
| 110 | ) : null} | ||
| 111 | </> | ||
| 112 | ); | ||
| 113 | } | 53 | } |
| 114 | 54 | ||
| 115 | function OpenVideoPreviewModal(props: { | 55 | function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { |
| 116 | readonly source: AttachmentVideoPreviewSource; | ||
| 117 | readonly onRequestClose: () => void; | ||
| 118 | }) { | ||
| 119 | const { source } = props; | ||
| 120 | const { attachment } = source; | 56 | const { attachment } = source; |
| 121 | const insets = useSafeAreaInsets(); | 57 | const [uri, setUri] = useState<string | null>(null); |
| 122 | const environmentId = source.type === "remote" ? source.environmentId : null; | 58 | const [error, setError] = useState<string | null>(null); |
| 123 | const preparedConnection = usePreparedConnection(environmentId); | 59 | // Only a different file needs a new lease; a metadata update on the same |
| 124 | const fileUri = source.type === "local" ? source.attachment.fileUri : null; | 60 | // draft must not dispose the file Android is still playing. |
| 125 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | 61 | const attachmentRef = useRef(attachment); |
| 126 | const assetUrl = useAssetUrlState( | 62 | attachmentRef.current = attachment; |
| 127 | environmentId, | 63 | const { id: attachmentId, fileUri } = attachment; |
| 128 | source.type === "remote" | ||
| 129 | ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } | ||
| 130 | : null, | ||
| 131 | ); | ||
| 132 | const [downloadUrl, setDownloadUrl] = useState<string | null>(null); | ||
| 133 | const [file, setFile] = useState<AttachmentPreviewFile | null>(null); | ||
| 134 | const [failure, setFailure] = useState<string | null>(null); | ||
| 135 | |||
| 136 | useEffect(() => Keyboard.dismiss(), []); | ||
| 137 | useEffect(() => { | ||
| 138 | if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { | ||
| 139 | setDownloadUrl(assetUrl.url); | ||
| 140 | } | ||
| 141 | }, [environmentId, downloadUrl, assetUrl]); | ||
| 142 | |||
| 143 | useEffect(() => { | 64 | useEffect(() => { |
| 144 | if (source.type === "remote" && downloadUrl === null) return; | 65 | setUri(null); |
| 66 | setError(null); | ||
| 145 | const controller = new AbortController(); | 67 | const controller = new AbortController(); |
| 146 | let preview: AttachmentPreviewFile | null = null; | 68 | const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); |
| 147 | setFile(null); | ||
| 148 | setFailure(null); | ||
| 149 | const loading = | ||
| 150 | source.type === "local" | ||
| 151 | ? loadLocalAttachmentPreview(source.attachment, controller.signal) | ||
| 152 | : downloadAttachmentForPreview({ | ||
| 153 | url: downloadUrl!, | ||
| 154 | attachment: { name: attachment.name, mimeType }, | ||
| 155 | signal: controller.signal, | ||
| 156 | }); | ||
| 157 | void loading.then( | 69 | void loading.then( |
| 158 | (loaded) => { | 70 | (file) => { |
| 159 | if (controller.signal.aborted) { | 71 | if (file === null) return; |
| 160 | loaded?.dispose(); | 72 | if (controller.signal.aborted) file.dispose(); |
| 161 | return; | 73 | else setUri(file.uri); |
| 162 | } | ||
| 163 | preview = loaded; | ||
| 164 | setFile(loaded); | ||
| 165 | }, | 74 | }, |
| 166 | (error: unknown) => { | 75 | (cause: unknown) => { |
| 167 | if (!controller.signal.aborted) { | 76 | if (!controller.signal.aborted) { |
| 168 | setFailure(error instanceof Error ? error.message : "Could not load this video."); | 77 | setError(cause instanceof Error ? cause.message : "Could not load this video."); |
| 169 | } | 78 | } |
| 170 | }, | 79 | }, |
| 171 | ); | 80 | ); |
| 172 | return () => { | 81 | return () => { |
| 173 | controller.abort(); | 82 | controller.abort(); |
| 174 | preview?.dispose(); | 83 | void loading.then( |
| 84 | (file) => file?.dispose(), | ||
| 85 | () => undefined, | ||
| 86 | ); | ||
| 175 | }; | 87 | }; |
| 176 | }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); | 88 | }, [attachmentId, fileUri]); |
| 89 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | ||
| 90 | return { | ||
| 91 | uri, | ||
| 92 | unavailable: error !== null, | ||
| 93 | error, | ||
| 94 | actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri }, | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | function MediaPreviewModal(props: { | ||
| 99 | readonly source: MediaVideoPreviewSource; | ||
| 100 | readonly onRequestClose: () => void; | ||
| 101 | }) { | ||
| 102 | return ( | ||
| 103 | <OpenVideoPreviewModal | ||
| 104 | name={props.source.name} | ||
| 105 | thumbnailKey={mediaVideoThumbnailKey(props.source)} | ||
| 106 | playback={useMediaPlayback(props.source)} | ||
| 107 | onRequestClose={props.onRequestClose} | ||
| 108 | /> | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | |||
| 112 | function LocalPreviewModal(props: { | ||
| 113 | readonly source: LocalVideoPreviewSource; | ||
| 114 | readonly onRequestClose: () => void; | ||
| 115 | }) { | ||
| 116 | return ( | ||
| 117 | <OpenVideoPreviewModal | ||
| 118 | name={props.source.attachment.name} | ||
| 119 | thumbnailKey={`local:${props.source.attachment.id}`} | ||
| 120 | playback={useLocalPlayback(props.source)} | ||
| 121 | onRequestClose={props.onRequestClose} | ||
| 122 | /> | ||
| 123 | ); | ||
| 124 | } | ||
| 177 | 125 | ||
| 178 | const loadError = | 126 | function OpenVideoPreviewModal(props: { |
| 179 | failure ?? | 127 | readonly name: string; |
| 180 | (environmentId !== null && downloadUrl === null | 128 | readonly thumbnailKey: string; |
| 181 | ? preparedConnection._tag === "None" | 129 | readonly playback: PlaybackState; |
| 182 | ? "This environment is disconnected. Reconnect and open the video again." | 130 | readonly onRequestClose: () => void; |
| 183 | : assetUrl._tag === "Failure" | 131 | }) { |
| 184 | ? "Could not load this video. Check the connection to this environment and try again." | 132 | const { playback } = props; |
| 185 | : null | 133 | const insets = useSafeAreaInsets(); |
| 186 | : null); | 134 | const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); |
| 187 | 135 | ||
| 136 | useEffect(() => Keyboard.dismiss(), []); | ||
| 188 | return ( | 137 | return ( |
| 189 | <Modal | 138 | <Modal |
| 190 | visible | 139 | visible |
| ... | @@ -200,8 +149,9 @@ function OpenVideoPreviewModal(props: { | ... | @@ -200,8 +149,9 @@ function OpenVideoPreviewModal(props: { |
| 200 | > | 149 | > |
| 201 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> | 150 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> |
| 202 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> | 151 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> |
| 203 | {attachment.name} | 152 | {props.name} |
| 204 | </AppText> | 153 | </AppText> |
| 154 | <MediaActionsMenu media={mediaActions} inModal /> | ||
| 205 | <Pressable | 155 | <Pressable |
| 206 | accessibilityRole="button" | 156 | accessibilityRole="button" |
| 207 | accessibilityLabel="Close video" | 157 | accessibilityLabel="Close video" |
| ... | @@ -211,22 +161,40 @@ function OpenVideoPreviewModal(props: { | ... | @@ -211,22 +161,40 @@ function OpenVideoPreviewModal(props: { |
| 211 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> | 161 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> |
| 212 | </Pressable> | 162 | </Pressable> |
| 213 | </View> | 163 | </View> |
| 214 | {file ? ( | 164 | <MediaSourceCaption source={mediaActions.title} /> |
| 215 | <VideoPlayback key={file.uri} file={file} /> | 165 | {playback.uri === null && !playback.unavailable ? ( |
| 216 | ) : ( | ||
| 217 | <View className="flex-1 items-center justify-center gap-3 px-6"> | 166 | <View className="flex-1 items-center justify-center gap-3 px-6"> |
| 218 | {loadError ? ( | 167 | <ActivityIndicator color="#ffffff" /> |
| 219 | <AppText accessibilityRole="alert" className="text-center text-base text-white/80"> | 168 | <AppText className="text-sm text-white/80">Loading video...</AppText> |
| 220 | {loadError} | ||
| 221 | </AppText> | ||
| 222 | ) : ( | ||
| 223 | <> | ||
| 224 | <ActivityIndicator color="#ffffff" /> | ||
| 225 | <AppText className="text-sm text-white/80">Loading video...</AppText> | ||
| 226 | </> | ||
| 227 | )} | ||
| 228 | </View> | 169 | </View> |
| 170 | ) : ( | ||
| 171 | <MediaVideoPlayer | ||
| 172 | uri={playback.uri} | ||
| 173 | resolvePlaybackUri={playback.resolvePlaybackUri} | ||
| 174 | name={props.name} | ||
| 175 | thumbnailKey={props.thumbnailKey} | ||
| 176 | unavailable={playback.unavailable} | ||
| 177 | paused={mediaActions.sharing} | ||
| 178 | expanded | ||
| 179 | autoPlay | ||
| 180 | /> | ||
| 229 | )} | 181 | )} |
| 182 | {playback.error ? ( | ||
| 183 | <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80"> | ||
| 184 | {playback.error} | ||
| 185 | </AppText> | ||
| 186 | ) : null} | ||
| 187 | <Pressable | ||
| 188 | accessibilityRole="button" | ||
| 189 | accessibilityLabel="Save or share video" | ||
| 190 | disabled={playback.uri === null || mediaActions.sharing} | ||
| 191 | onPress={mediaActions.share} | ||
| 192 | className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4" | ||
| 193 | > | ||
| 194 | <AppText className="font-t3-medium text-base text-white"> | ||
| 195 | {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} | ||
| 196 | </AppText> | ||
| 197 | </Pressable> | ||
| 230 | </View> | 198 | </View> |
| 231 | </Modal> | 199 | </Modal> |
| 232 | ); | 200 | ); |
| ... | @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { | ... | @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { |
| 243 | }, [isFocused, hasSource, props.onRequestClose]); | 211 | }, [isFocused, hasSource, props.onRequestClose]); |
| 244 | const { source } = props; | 212 | const { source } = props; |
| 245 | if (source === null || !isFocused) return null; | 213 | if (source === null || !isFocused) return null; |
| 246 | if (source.type === "media") { | 214 | return source.type === "local" ? ( |
| 247 | return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />; | 215 | <LocalPreviewModal |
| 248 | } | 216 | key={`local:${source.attachment.id}:${source.attachment.fileUri}`} |
| 249 | const key = | 217 | source={source} |
| 250 | source.type === "local" | 218 | onRequestClose={props.onRequestClose} |
| 251 | ? `local:${source.attachment.id}:${source.attachment.fileUri}` | 219 | /> |
| 252 | : `remote:${source.environmentId}:${source.attachment.id}`; | 220 | ) : ( |
| 253 | return <OpenVideoPreviewModal key={key} source={source} onRequestClose={props.onRequestClose} />; | 221 | <MediaPreviewModal |
| 222 | key={mediaVideoThumbnailKey(source)} | ||
| 223 | source={source} | ||
| 224 | onRequestClose={props.onRequestClose} | ||
| 225 | /> | ||
| 226 | ); | ||
| 254 | } | 227 | } |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+1-1| ... | @@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -668,7 +668,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 668 | id: action.id, | 668 | id: action.id, |
| 669 | title: action.title, | 669 | title: action.title, |
| 670 | icon: | 670 | icon: |
| 671 | action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), | 671 | action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), |
| 672 | inline: false, | 672 | inline: false, |
| 673 | onPress: action.run, | 673 | onPress: action.run, |
| 674 | })) | 674 | })) |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+38-75| ... | @@ -1,11 +1,8 @@ | ... | @@ -1,11 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useId, useMemo, useState } from "react"; | 1 | import { useId, useMemo, useState } from "react"; |
| 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; | 2 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 5 | 3 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { EmptyState } from "../../components/EmptyState"; | 5 | import { EmptyState } from "../../components/EmptyState"; |
| 8 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; | ||
| 9 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 6 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 10 | import { PresentationSource } from "../../components/NativePresentation"; | 7 | import { PresentationSource } from "../../components/NativePresentation"; |
| 11 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | 8 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; |
| ... | @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { | ... | @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 27 | 24 | ||
| 28 | return ( | 25 | return ( |
| 29 | <View className="relative flex-1 bg-subtle"> | 26 | <View className="relative flex-1 bg-subtle"> |
| 30 | <Pressable | 27 | <MediaActionsMenu media={mediaActions} style={{ flex: 1 }}> |
| 31 | accessibilityRole="button" | 28 | <Pressable |
| 32 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} | 29 | accessibilityRole="button" |
| 33 | disabled={loadError !== null} | 30 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} |
| 34 | className="flex-1 p-4 active:bg-subtle-strong" | 31 | accessibilityHint={ |
| 35 | onPress={() => | 32 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined |
| 36 | setPreview({ | 33 | } |
| 37 | kind: "image", | 34 | disabled={loadError !== null} |
| 38 | uri: props.uri, | 35 | className="flex-1 p-4 active:bg-subtle-strong" |
| 39 | name: props.accessibilityLabel, | 36 | onPress={() => |
| 40 | sourceIdentifier, | 37 | setPreview({ |
| 41 | actionsSource: props.actionsSource, | 38 | kind: "image", |
| 42 | }) | 39 | uri: props.uri, |
| 43 | } | 40 | name: props.accessibilityLabel, |
| 44 | > | 41 | sourceIdentifier, |
| 45 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> | 42 | actionsSource: props.actionsSource, |
| 46 | <Image | 43 | }) |
| 47 | accessible={false} | 44 | } |
| 48 | source={imageSource} | 45 | > |
| 49 | className="h-full w-full" | 46 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> |
| 50 | resizeMode="contain" | 47 | <Image |
| 51 | onLoadStart={() => setLoadError(null)} | 48 | accessible={false} |
| 52 | onError={(event) => { | 49 | source={imageSource} |
| 53 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | 50 | className="h-full w-full" |
| 54 | }} | 51 | resizeMode="contain" |
| 55 | /> | 52 | onLoadStart={() => setLoadError(null)} |
| 56 | </PresentationSource> | 53 | onError={(event) => { |
| 57 | </Pressable> | 54 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); |
| 55 | }} | ||
| 56 | /> | ||
| 57 | </PresentationSource> | ||
| 58 | </Pressable> | ||
| 59 | </MediaActionsMenu> | ||
| 58 | {loadError !== null ? ( | 60 | {loadError !== null ? ( |
| 59 | <View className="absolute inset-0 items-center justify-center bg-card px-6"> | 61 | <View |
| 62 | pointerEvents="none" | ||
| 63 | className="absolute inset-0 items-center justify-center bg-card px-6" | ||
| 64 | > | ||
| 60 | <EmptyState title="Image unavailable" detail={loadError} /> | 65 | <EmptyState title="Image unavailable" detail={loadError} /> |
| 61 | </View> | 66 | </View> |
| 62 | ) : null} | 67 | ) : null} |
| 63 | |||
| 64 | <View className="absolute right-2 top-2"> | ||
| 65 | <MediaActionsMenu media={mediaActions} /> | ||
| 66 | </View> | ||
| 67 | |||
| 68 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | 68 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 69 | </View> | 69 | </View> |
| 70 | ); | 70 | ); |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | function CachedWorkspaceFileImagePreview(props: { | ||
| 74 | readonly accessibilityLabel: string; | ||
| 75 | readonly uri: string; | ||
| 76 | readonly actionsSource?: MediaActionsSource; | ||
| 77 | }) { | ||
| 78 | const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); | ||
| 79 | const imageResult = useAtomValue(imageAtom); | ||
| 80 | |||
| 81 | if (AsyncResult.isFailure(imageResult)) { | ||
| 82 | return ( | ||
| 83 | <View className="flex-1 items-center justify-center bg-card px-6"> | ||
| 84 | <EmptyState | ||
| 85 | title="Image unavailable" | ||
| 86 | detail="The image could not be loaded into the local cache." | ||
| 87 | /> | ||
| 88 | </View> | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 92 | if (!AsyncResult.isSuccess(imageResult)) { | ||
| 93 | return ( | ||
| 94 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | ||
| 95 | <ActivityIndicator /> | ||
| 96 | <Text className="text-center text-sm text-foreground-muted">Loading image...</Text> | ||
| 97 | </View> | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | return ( | ||
| 102 | <ResolvedWorkspaceFileImagePreview | ||
| 103 | accessibilityLabel={props.accessibilityLabel} | ||
| 104 | uri={imageResult.value} | ||
| 105 | actionsSource={props.actionsSource} | ||
| 106 | /> | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | export function WorkspaceFileImagePreview(props: { | 73 | export function WorkspaceFileImagePreview(props: { |
| 111 | readonly accessibilityLabel: string; | 74 | readonly accessibilityLabel: string; |
| 112 | readonly uri: string | null; | 75 | readonly uri: string | null; |
| ... | @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { | ... | @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { |
| 124 | } | 87 | } |
| 125 | 88 | ||
| 126 | return ( | 89 | return ( |
| 127 | <CachedWorkspaceFileImagePreview | 90 | <ResolvedWorkspaceFileImagePreview |
| 128 | accessibilityLabel={props.accessibilityLabel} | 91 | accessibilityLabel={props.accessibilityLabel} |
| 129 | uri={props.uri} | 92 | uri={props.uri} |
| 130 | actionsSource={props.actionsSource} | 93 | actionsSource={props.actionsSource} |
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx-7| ... | @@ -1,9 +1,7 @@ | ... | @@ -1,9 +1,7 @@ |
| 1 | import { useState } from "react"; | ||
| 2 | import { View } from "react-native"; | 1 | import { View } from "react-native"; |
| 3 | 2 | ||
| 4 | import { EmptyState } from "../../components/EmptyState"; | 3 | import { EmptyState } from "../../components/EmptyState"; |
| 5 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | 4 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 6 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | ||
| 7 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; | 5 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 8 | 6 | ||
| 9 | /** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ | 7 | /** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ |
| ... | @@ -15,7 +13,6 @@ export function WorkspaceFileVideoPreview(props: { | ... | @@ -15,7 +13,6 @@ export function WorkspaceFileVideoPreview(props: { |
| 15 | readonly resolvePlaybackUri: () => Promise<string | null>; | 13 | readonly resolvePlaybackUri: () => Promise<string | null>; |
| 16 | readonly unavailable: boolean; | 14 | readonly unavailable: boolean; |
| 17 | }) { | 15 | }) { |
| 18 | const [preview, setPreview] = useState<VideoPreviewSource | null>(null); | ||
| 19 | const uri = props.uri; | 16 | const uri = props.uri; |
| 20 | 17 | ||
| 21 | if (props.unavailable) { | 18 | if (props.unavailable) { |
| ... | @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { | ... | @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { |
| 37 | name={props.name} | 34 | name={props.name} |
| 38 | thumbnailKey={props.thumbnailKey} | 35 | thumbnailKey={props.thumbnailKey} |
| 39 | actionsSource={props.source?.actionsSource} | 36 | actionsSource={props.source?.actionsSource} |
| 40 | onExpand={ | ||
| 41 | uri === null || props.source === null ? undefined : () => setPreview(props.source) | ||
| 42 | } | ||
| 43 | /> | 37 | /> |
| 44 | <VideoPreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | ||
| 45 | </View> | 38 | </View> |
| 46 | ); | 39 | ); |
| 47 | } | 40 | } |
apps/mobile/src/features/files/workspace-file-image-cache.test.ts deleted-64| ... | @@ -1,64 +0,0 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; | ||
| 6 | |||
| 7 | describe("workspaceFileImageAtom", () => { | ||
| 8 | it("reuses a prefetched image across route remounts", async () => { | ||
| 9 | const prefetch = vi.fn(async () => true); | ||
| 10 | const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); | ||
| 11 | const registry = AtomRegistry.make({ timeoutResolution: 1 }); | ||
| 12 | const first = imageAtom("https://example.test/image.png"); | ||
| 13 | const firstUnmount = registry.mount(first); | ||
| 14 | |||
| 15 | await vi.waitFor(() => { | ||
| 16 | expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); | ||
| 17 | }); | ||
| 18 | firstUnmount(); | ||
| 19 | |||
| 20 | const remounted = imageAtom("https://example.test/image.png"); | ||
| 21 | const secondUnmount = registry.mount(remounted); | ||
| 22 | |||
| 23 | expect(remounted).toBe(first); | ||
| 24 | expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); | ||
| 25 | expect(prefetch).toHaveBeenCalledTimes(1); | ||
| 26 | |||
| 27 | secondUnmount(); | ||
| 28 | registry.dispose(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("prefetches different asset URLs independently", async () => { | ||
| 32 | const prefetch = vi.fn(async () => true); | ||
| 33 | const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); | ||
| 34 | const registry = AtomRegistry.make(); | ||
| 35 | const first = imageAtom("https://example.test/first.png"); | ||
| 36 | const second = imageAtom("https://example.test/second.png"); | ||
| 37 | const firstUnmount = registry.mount(first); | ||
| 38 | const secondUnmount = registry.mount(second); | ||
| 39 | |||
| 40 | await vi.waitFor(() => { | ||
| 41 | expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); | ||
| 42 | expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); | ||
| 43 | }); | ||
| 44 | expect(prefetch).toHaveBeenCalledTimes(2); | ||
| 45 | |||
| 46 | firstUnmount(); | ||
| 47 | secondUnmount(); | ||
| 48 | registry.dispose(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("exposes prefetch failures", async () => { | ||
| 52 | const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); | ||
| 53 | const registry = AtomRegistry.make(); | ||
| 54 | const atom = imageAtom("https://example.test/missing.png"); | ||
| 55 | const unmount = registry.mount(atom); | ||
| 56 | |||
| 57 | await vi.waitFor(() => { | ||
| 58 | expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); | ||
| 59 | }); | ||
| 60 | |||
| 61 | unmount(); | ||
| 62 | registry.dispose(); | ||
| 63 | }); | ||
| 64 | }); | ||
apps/mobile/src/features/files/workspace-file-image-cache.ts deleted-48| ... | @@ -1,48 +0,0 @@ | ||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; | ||
| 6 | |||
| 7 | type ImagePrefetch = (uri: string) => Promise<boolean>; | ||
| 8 | |||
| 9 | class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} | ||
| 10 | |||
| 11 | export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ | ||
| 12 | readonly cause?: unknown; | ||
| 13 | readonly uri: string; | ||
| 14 | }> {} | ||
| 15 | |||
| 16 | async function prefetchWithNativeImage(uri: string): Promise<boolean> { | ||
| 17 | const { Image } = await import("react-native"); | ||
| 18 | return Image.prefetch(uri); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function createWorkspaceFileImageAtomFamily(options?: { | ||
| 22 | readonly idleTtlMs?: number; | ||
| 23 | readonly prefetch?: ImagePrefetch; | ||
| 24 | }) { | ||
| 25 | const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; | ||
| 26 | const prefetch = options?.prefetch ?? prefetchWithNativeImage; | ||
| 27 | const family = Atom.family((key: WorkspaceImageCacheKey) => | ||
| 28 | Atom.make( | ||
| 29 | Effect.tryPromise({ | ||
| 30 | try: async () => { | ||
| 31 | const cached = await prefetch(key.uri); | ||
| 32 | if (!cached) { | ||
| 33 | throw new WorkspaceImagePrefetchError({ uri: key.uri }); | ||
| 34 | } | ||
| 35 | return key.uri; | ||
| 36 | }, | ||
| 37 | catch: (cause) => | ||
| 38 | cause instanceof WorkspaceImagePrefetchError | ||
| 39 | ? cause | ||
| 40 | : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), | ||
| 41 | }), | ||
| 42 | ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), | ||
| 43 | ); | ||
| 44 | |||
| 45 | return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); | ||
| 46 | } | ||
| 47 | |||
| 48 | export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); | ||
apps/mobile/src/features/threads/ThreadFeed.tsx+87-71| ... | @@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; | ... | @@ -105,6 +105,7 @@ import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; |
| 105 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | 105 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; |
| 106 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | 106 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 107 | import { | 107 | import { |
| 108 | attachmentVideoPreviewSource, | ||
| 108 | mediaVideoPreviewUri, | 109 | mediaVideoPreviewUri, |
| 109 | mediaVideoThumbnailKey, | 110 | mediaVideoThumbnailKey, |
| 110 | type MediaVideoPreviewSource, | 111 | type MediaVideoPreviewSource, |
| ... | @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { | ... | @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { |
| 251 | readonly environmentId: EnvironmentId; | 252 | readonly environmentId: EnvironmentId; |
| 252 | readonly attachmentId: string; | 253 | readonly attachmentId: string; |
| 253 | readonly name: string; | 254 | readonly name: string; |
| 255 | readonly mimeType: string; | ||
| 254 | readonly className: string; | 256 | readonly className: string; |
| 255 | readonly onPressPreview: (source: FilePreviewSource) => void; | 257 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 256 | }) { | 258 | }) { |
| 257 | const sourceIdentifier = useId(); | 259 | const sourceIdentifier = useId(); |
| 258 | const uri = useAssetUrl(props.environmentId, { | 260 | const resource = useMemo( |
| 259 | _tag: "attachment", | 261 | () => ({ |
| 260 | attachmentId: props.attachmentId, | 262 | _tag: "attachment" as const, |
| 261 | }); | 263 | attachmentId: props.attachmentId, |
| 264 | fileName: props.name, | ||
| 265 | mimeType: props.mimeType, | ||
| 266 | }), | ||
| 267 | [props.attachmentId, props.name, props.mimeType], | ||
| 268 | ); | ||
| 269 | const uri = useAssetUrl(props.environmentId, resource); | ||
| 262 | 270 | ||
| 263 | if (uri === null) { | 271 | if (uri === null) { |
| 264 | return ( | 272 | return ( |
| ... | @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { | ... | @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { |
| 274 | accessibilityRole="imagebutton" | 282 | accessibilityRole="imagebutton" |
| 275 | accessibilityLabel={`Open ${props.name}`} | 283 | accessibilityLabel={`Open ${props.name}`} |
| 276 | onPress={() => | 284 | onPress={() => |
| 277 | props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) | 285 | // The viewer mints its own URL from the resource so the image survives a refresh. |
| 286 | props.onPressPreview({ | ||
| 287 | kind: "image", | ||
| 288 | environmentId: props.environmentId, | ||
| 289 | resource, | ||
| 290 | name: props.name, | ||
| 291 | sourceIdentifier, | ||
| 292 | actionsSource: { | ||
| 293 | name: props.name, | ||
| 294 | mimeType: props.mimeType, | ||
| 295 | environmentId: props.environmentId, | ||
| 296 | resource, | ||
| 297 | }, | ||
| 298 | }) | ||
| 278 | } | 299 | } |
| 279 | > | 300 | > |
| 280 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> | 301 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> |
| ... | @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { | ... | @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { |
| 389 | }; | 410 | }; |
| 390 | 411 | ||
| 391 | if (videoType !== null) { | 412 | if (videoType !== null) { |
| 413 | const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; | ||
| 392 | return ( | 414 | return ( |
| 393 | <VideoAttachmentTile | 415 | <VideoAttachmentTile |
| 394 | name={attachment.name} | 416 | name={attachment.name} |
| 395 | sourceIdentifier={`attachment:${props.environmentId}:${attachment.id}`} | 417 | sourceIdentifier={sourceIdentifier} |
| 396 | thumbnailSource={thumbnailUrl} | 418 | thumbnailSource={thumbnailUrl} |
| 419 | actionsSource={ | ||
| 420 | attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier) | ||
| 421 | .actionsSource | ||
| 422 | } | ||
| 397 | disabled={opening || httpBaseUrl === null} | 423 | disabled={opening || httpBaseUrl === null} |
| 398 | onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} | 424 | onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} |
| 399 | onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} | ||
| 400 | className="my-1 rounded-2xl" | 425 | className="my-1 rounded-2xl" |
| 401 | style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} | 426 | style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} |
| 402 | /> | 427 | /> |
| ... | @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { | ... | @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { |
| 520 | style={{ alignSelf: "stretch", gap: 6 }} | 545 | style={{ alignSelf: "stretch", gap: 6 }} |
| 521 | > | 546 | > |
| 522 | {props.uri === null || failed ? ( | 547 | {props.uri === null || failed ? ( |
| 523 | <View | 548 | <MediaActionsMenu media={mediaActions}> |
| 524 | className="items-center justify-center rounded-[10px] bg-md-code-bg" | 549 | <Pressable |
| 525 | style={{ | 550 | accessibilityRole="imagebutton" |
| 526 | ...frameStyle, | 551 | accessibilityLabel={props.alt ?? "Markdown image"} |
| 527 | }} | 552 | accessibilityHint={ |
| 528 | > | 553 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined |
| 529 | {failed ? ( | 554 | } |
| 530 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> | 555 | className="items-center justify-center rounded-[10px] bg-md-code-bg" |
| 531 | ) : ( | 556 | style={frameStyle} |
| 532 | <ActivityIndicator /> | 557 | > |
| 533 | )} | 558 | {failed ? ( |
| 534 | {props.actionsSource ? ( | 559 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> |
| 535 | <View className="absolute right-1 top-1"> | 560 | ) : ( |
| 536 | <MediaActionsMenu media={mediaActions} /> | 561 | <ActivityIndicator /> |
| 537 | </View> | 562 | )} |
| 538 | ) : null} | 563 | </Pressable> |
| 539 | </View> | 564 | </MediaActionsMenu> |
| 540 | ) : ( | 565 | ) : ( |
| 541 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> | 566 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 542 | <View> | 567 | <MediaActionsMenu media={mediaActions}> |
| 543 | <MediaActionsMenu media={mediaActions}> | 568 | <Pressable |
| 544 | <Pressable | 569 | accessibilityRole="imagebutton" |
| 545 | accessibilityRole="imagebutton" | 570 | accessibilityLabel={props.alt ?? "Markdown image"} |
| 546 | accessibilityLabel={props.alt ?? "Markdown image"} | 571 | accessibilityHint={ |
| 547 | onPress={() => | 572 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined |
| 548 | props.onPressPreview({ | 573 | } |
| 549 | kind: "image", | 574 | onPress={() => |
| 550 | uri: props.uri!, | 575 | // Quick Look picks the viewer from the name's extension, so it needs the |
| 551 | name: props.alt ?? "Image", | 576 | // file name rather than the alt text. |
| 552 | sourceIdentifier, | 577 | props.onPressPreview({ |
| 553 | actionsSource: props.actionsSource, | 578 | kind: "image", |
| 554 | }) | 579 | uri: props.uri!, |
| 555 | } | 580 | name: props.actionsSource?.name ?? props.alt ?? "Image", |
| 556 | style={{ alignSelf: "flex-start" }} | 581 | sourceIdentifier, |
| 582 | actionsSource: props.actionsSource, | ||
| 583 | }) | ||
| 584 | } | ||
| 585 | style={{ alignSelf: "flex-start" }} | ||
| 586 | > | ||
| 587 | <View | ||
| 588 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | ||
| 589 | style={{ | ||
| 590 | ...frameStyle, | ||
| 591 | }} | ||
| 557 | > | 592 | > |
| 558 | <View | 593 | <ThreadMarkdownImageRequest |
| 559 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | 594 | key={props.uri} |
| 560 | style={{ | 595 | uri={props.uri} |
| 561 | ...frameStyle, | 596 | onLoad={setSourceSize} |
| 562 | }} | 597 | onError={() => setFailedUri(props.uri)} |
| 563 | > | 598 | /> |
| 564 | <ThreadMarkdownImageRequest | ||
| 565 | key={props.uri} | ||
| 566 | uri={props.uri} | ||
| 567 | onLoad={setSourceSize} | ||
| 568 | onError={() => setFailedUri(props.uri)} | ||
| 569 | /> | ||
| 570 | </View> | ||
| 571 | </Pressable> | ||
| 572 | </MediaActionsMenu> | ||
| 573 | {props.actionsSource ? ( | ||
| 574 | <View className="absolute right-1 top-1"> | ||
| 575 | <MediaActionsMenu media={mediaActions} /> | ||
| 576 | </View> | 599 | </View> |
| 577 | ) : null} | 600 | </Pressable> |
| 578 | </View> | 601 | </MediaActionsMenu> |
| 579 | </PresentationSource> | 602 | </PresentationSource> |
| 580 | )} | 603 | )} |
| 581 | {props.alt ? ( | 604 | {props.alt ? ( |
| ... | @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { | ... | @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { |
| 658 | return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; | 681 | return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; |
| 659 | } | 682 | } |
| 660 | 683 | ||
| 661 | function ThreadMarkdownVideo(props: { | 684 | function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { |
| 662 | readonly source: MediaVideoPreviewSource; | ||
| 663 | readonly onExpand: (source: MediaVideoPreviewSource) => void; | ||
| 664 | }) { | ||
| 665 | const { source } = props; | 685 | const { source } = props; |
| 666 | const visible = useContext(ThreadMediaVisibleContext); | 686 | const visible = useContext(ThreadMediaVisibleContext); |
| 667 | const thumbnailKey = mediaVideoThumbnailKey(source); | 687 | const thumbnailKey = mediaVideoThumbnailKey(source); |
| ... | @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { | ... | @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { |
| 688 | thumbnailVisible={visible} | 708 | thumbnailVisible={visible} |
| 689 | unavailable={"resource" in source && asset._tag === "Failure"} | 709 | unavailable={"resource" in source && asset._tag === "Failure"} |
| 690 | actionsSource={source.actionsSource} | 710 | actionsSource={source.actionsSource} |
| 691 | onExpand={() => props.onExpand(source)} | ||
| 692 | /> | 711 | /> |
| 693 | ); | 712 | ); |
| 694 | } | 713 | } |
| ... | @@ -1593,6 +1612,7 @@ function renderFeedEntry( | ... | @@ -1593,6 +1612,7 @@ function renderFeedEntry( |
| 1593 | environmentId={props.environmentId} | 1612 | environmentId={props.environmentId} |
| 1594 | attachmentId={attachment.id} | 1613 | attachmentId={attachment.id} |
| 1595 | name={attachment.name} | 1614 | name={attachment.name} |
| 1615 | mimeType={attachment.mimeType} | ||
| 1596 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" | 1616 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1597 | onPressPreview={props.onPressPreview} | 1617 | onPressPreview={props.onPressPreview} |
| 1598 | /> | 1618 | /> |
| ... | @@ -1656,6 +1676,7 @@ function renderFeedEntry( | ... | @@ -1656,6 +1676,7 @@ function renderFeedEntry( |
| 1656 | environmentId={props.environmentId} | 1676 | environmentId={props.environmentId} |
| 1657 | attachmentId={attachment.id} | 1677 | attachmentId={attachment.id} |
| 1658 | name={attachment.name} | 1678 | name={attachment.name} |
| 1679 | mimeType={attachment.mimeType} | ||
| 1659 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" | 1680 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" |
| 1660 | onPressPreview={props.onPressPreview} | 1681 | onPressPreview={props.onPressPreview} |
| 1661 | /> | 1682 | /> |
| ... | @@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2218 | <ThreadMarkdownVideo | 2239 | <ThreadMarkdownVideo |
| 2219 | key={image.href} | 2240 | key={image.href} |
| 2220 | source={{ ...media.source, name: image.alt ?? media.source.name }} | 2241 | source={{ ...media.source, name: image.alt ?? media.source.name }} |
| 2221 | onExpand={(source) => setExpandedVideo((current) => current ?? source)} | ||
| 2222 | /> | 2242 | /> |
| 2223 | ); | 2243 | ); |
| 2224 | } | 2244 | } |
| ... | @@ -2667,12 +2687,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2667,12 +2687,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2667 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { | 2687 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { |
| 2668 | setExpandedVideo( | 2688 | setExpandedVideo( |
| 2669 | (current) => | 2689 | (current) => |
| 2670 | current ?? { | 2690 | current ?? |
| 2671 | type: "remote", | 2691 | attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), |
| 2672 | environmentId: props.environmentId, | ||
| 2673 | attachment, | ||
| 2674 | sourceIdentifier, | ||
| 2675 | }, | ||
| 2676 | ); | 2692 | ); |
| 2677 | }, | 2693 | }, |
| 2678 | [props.environmentId], | 2694 | [props.environmentId], |
apps/mobile/src/lib/markdownMedia.test.ts+11| ... | @@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => { | ... | @@ -61,6 +61,17 @@ describe("resolveMarkdownMediaPreview", () => { |
| 61 | }); | 61 | }); |
| 62 | }); | 62 | }); |
| 63 | 63 | ||
| 64 | it("serves a linked T3 attachment file in place like any other host path", () => { | ||
| 65 | const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4"; | ||
| 66 | expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({ | ||
| 67 | kind: "video", | ||
| 68 | source: { | ||
| 69 | resource: { _tag: "media-file", threadId: input.threadId, path }, | ||
| 70 | actionsSource: { resource: { _tag: "media-file", path } }, | ||
| 71 | }, | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 64 | it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { | 75 | it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { |
| 65 | expect( | 76 | expect( |
| 66 | resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), | 77 | resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), |
apps/mobile/src/lib/markdownMedia.ts+13-53| ... | @@ -1,15 +1,6 @@ | ... | @@ -1,15 +1,6 @@ |
| 1 | import { | 1 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 2 | classifyMarkdownImageSource, | ||
| 3 | markdownImageSourceFragment, | ||
| 4 | } from "@t3tools/client-runtime/markdown-images"; | ||
| 5 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 6 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | 3 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; |
| 7 | import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | ||
| 8 | import { | ||
| 9 | mediaFileReference, | ||
| 10 | mediaReferenceFileName, | ||
| 11 | mediaUrlReference, | ||
| 12 | } from "@t3tools/client-runtime/media-reference"; | ||
| 13 | 4 | ||
| 14 | import type { FilePreviewSource } from "../components/FilePreviewModal"; | 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; |
| 15 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; | 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; |
| ... | @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( | ... | @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( |
| 29 | | { readonly kind: "image"; readonly source: FilePreviewSource } | 20 | | { readonly kind: "image"; readonly source: FilePreviewSource } |
| 30 | | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } | 21 | | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } |
| 31 | | null { | 22 | | null { |
| 32 | const classified = classifyMarkdownImageSource(href, input.workspaceRoot); | 23 | const media = resolveMediaSource(href, input); |
| 33 | if (classified._tag === "Blocked") return null; | 24 | if (media === null || media.access === "unavailable") return null; |
| 34 | const path = | 25 | const { kind, name, mimeType, reference, srcFragment } = media; |
| 35 | classified._tag === "WorkspaceFile" | 26 | |
| 36 | ? classified.path.replace(/:\d+(?::\d+)?$/, "") | ||
| 37 | : classified.uri.split(/[?#]/, 1)[0]!; | ||
| 38 | const basename = path.split(/[\\/]/).at(-1) ?? ""; | ||
| 39 | const extensionIndex = basename.lastIndexOf("."); | ||
| 40 | // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. | ||
| 41 | const detectedMimeType = | ||
| 42 | classified._tag === "Direct" | ||
| 43 | ? mediaMimeType(classified.uri) | ||
| 44 | : extensionIndex < 0 | ||
| 45 | ? null | ||
| 46 | : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); | ||
| 47 | const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); | ||
| 48 | if (mimeType === null) return null; | ||
| 49 | const kind = mimeType.startsWith("video/") ? "video" : "image"; | ||
| 50 | const reference = | ||
| 51 | classified._tag === "Direct" | ||
| 52 | ? mediaUrlReference(classified.uri) | ||
| 53 | : mediaFileReference(path, input.workspaceRoot); | ||
| 54 | const name = | ||
| 55 | (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); | ||
| 56 | const srcFragment = markdownImageSourceFragment(href); | ||
| 57 | const target = | 27 | const target = |
| 58 | classified._tag === "Direct" | 28 | media.access === "direct" |
| 59 | ? { uri: normalizeNativeMarkdownUrl(classified.uri) } | 29 | ? { uri: normalizeNativeMarkdownUrl(media.uri) } |
| 60 | : { | 30 | : { |
| 61 | environmentId: input.environmentId, | 31 | environmentId: input.environmentId, |
| 62 | resource: { | 32 | resource: media.resource, |
| 63 | _tag: "media-file" as const, | ||
| 64 | threadId: input.threadId, | ||
| 65 | path, | ||
| 66 | }, | ||
| 67 | ...(srcFragment ? { srcFragment } : {}), | 33 | ...(srcFragment ? { srcFragment } : {}), |
| 68 | }; | 34 | }; |
| 69 | const actionsSource: MediaActionsSource = | 35 | const actionsSource: MediaActionsSource = |
| 70 | classified._tag === "Direct" | 36 | media.access === "direct" |
| 71 | ? { reference, uri: classified.uri, name, mimeType } | 37 | ? { reference, uri: media.uri, name, mimeType } |
| 72 | : { | 38 | : { |
| 73 | reference, | 39 | reference, |
| 74 | environmentId: input.environmentId, | 40 | environmentId: input.environmentId, |
| 75 | threadId: input.threadId, | 41 | threadId: input.threadId, |
| 76 | resource: { _tag: "media-file", threadId: input.threadId, path }, | 42 | resource: media.resource, |
| 77 | name, | 43 | name, |
| 78 | mimeType, | 44 | mimeType, |
| 79 | }; | 45 | }; |
| 80 | return kind === "video" | 46 | return kind === "video" |
| 81 | ? { | 47 | ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } } |
| 82 | kind, | 48 | : { kind, source: { kind, name, ...target, actionsSource } }; |
| 83 | source: { type: "media", name, mimeType, ...target, actionsSource }, | ||
| 84 | } | ||
| 85 | : { | ||
| 86 | kind, | ||
| 87 | source: { kind, name, ...target, actionsSource }, | ||
| 88 | }; | ||
| 89 | } | 49 | } |
apps/mobile/src/lib/mediaActions.ts+73-46| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | ||
| 2 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; | 3 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; |
| 3 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | 5 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; |
| ... | @@ -7,18 +8,23 @@ import { Alert } from "react-native"; | ... | @@ -7,18 +8,23 @@ import { Alert } from "react-native"; |
| 7 | 8 | ||
| 8 | import { useRefreshAssetUrl } from "../state/assets"; | 9 | import { useRefreshAssetUrl } from "../state/assets"; |
| 9 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; | 10 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; |
| 11 | import type { DraftComposerFileAttachment } from "./composerImages"; | ||
| 10 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | 12 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; |
| 13 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | ||
| 11 | 14 | ||
| 12 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ | 15 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ |
| 13 | export type MediaActionsSource = { | 16 | export type MediaActionsSource = { |
| 14 | readonly reference?: MediaReference; | 17 | readonly reference?: MediaReference; |
| 15 | readonly name: string; | 18 | readonly name: string; |
| 16 | readonly mimeType: string; | 19 | readonly mimeType: string; |
| 20 | /** Anchors the iOS share sheet to the view that opened the menu. */ | ||
| 21 | readonly sourceIdentifier?: string; | ||
| 17 | } & ( | 22 | } & ( |
| 18 | | { readonly uri: string } | 23 | | { readonly uri: string } |
| 24 | | { readonly attachment: DraftComposerFileAttachment } | ||
| 19 | | { | 25 | | { |
| 20 | readonly environmentId: EnvironmentId; | 26 | readonly environmentId: EnvironmentId; |
| 21 | readonly threadId: ThreadId; | 27 | readonly threadId?: ThreadId; |
| 22 | readonly resource: AssetResource; | 28 | readonly resource: AssetResource; |
| 23 | } | 29 | } |
| 24 | ); | 30 | ); |
| ... | @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi | ... | @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi |
| 39 | controller.current = request; | 45 | controller.current = request; |
| 40 | setSharing(true); | 46 | setSharing(true); |
| 41 | void (async () => { | 47 | void (async () => { |
| 48 | if ("attachment" in source) { | ||
| 49 | const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); | ||
| 50 | if (!preview) return; | ||
| 51 | try { | ||
| 52 | await preview.share(request.signal, source.sourceIdentifier); | ||
| 53 | } finally { | ||
| 54 | preview.dispose(); | ||
| 55 | } | ||
| 56 | return; | ||
| 57 | } | ||
| 42 | const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); | 58 | const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); |
| 43 | if (request.signal.aborted) return; | 59 | if (request.signal.aborted) return; |
| 44 | if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); | 60 | if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); |
| 45 | const input = { | 61 | const input = { |
| 46 | attachment: { name: source.name, mimeType: source.mimeType }, | 62 | attachment: { name: source.name, mimeType: source.mimeType }, |
| 47 | signal: request.signal, | 63 | signal: request.signal, |
| 64 | sourceIdentifier: source.sourceIdentifier, | ||
| 48 | }; | 65 | }; |
| 49 | if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); | 66 | if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); |
| 50 | else await downloadAndShareAttachment({ ...input, url: uri }); | 67 | else await downloadAndShareAttachment({ ...input, url: uri }); |
| ... | @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi | ... | @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi |
| 66 | }; | 83 | }; |
| 67 | 84 | ||
| 68 | const reference = source?.reference; | 85 | const reference = source?.reference; |
| 69 | const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source | 86 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; |
| 70 | ? [ | 87 | const threadId = source && "threadId" in source ? source.threadId : undefined; |
| 71 | ...(reference?.kind === "file" | 88 | const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = |
| 72 | ? [ | 89 | source |
| 73 | { | 90 | ? [ |
| 74 | id: "copy-path", | 91 | ...(reference?.kind === "file" |
| 75 | title: "Copy full path", | 92 | ? [ |
| 76 | run: () => copyTextWithHaptic(reference.path), | 93 | { |
| 77 | }, | 94 | id: "copy-full-path" as const, |
| 78 | ...(reference.relativePath | 95 | title: "Copy full path", |
| 79 | ? [ | 96 | run: () => copyTextWithHaptic(reference.path), |
| 80 | { | 97 | }, |
| 81 | id: "copy-relative-path", | 98 | ] |
| 82 | title: "Copy relative path", | 99 | : []), |
| 83 | run: () => copyTextWithHaptic(reference.relativePath!), | 100 | ...(relativePath |
| 84 | }, | 101 | ? [ |
| 85 | ] | 102 | { |
| 86 | : []), | 103 | id: "copy-relative-path" as const, |
| 87 | ...(reference.relativePath && source && "environmentId" in source | 104 | title: "Copy relative path", |
| 88 | ? [ | 105 | run: () => copyTextWithHaptic(relativePath), |
| 89 | { | 106 | }, |
| 90 | id: "open-file", | 107 | ] |
| 91 | title: "Open in file viewer", | 108 | : []), |
| 92 | run: () => { | 109 | ...(reference?.kind === "url" |
| 93 | onOpenFile?.(); | 110 | ? [ |
| 94 | navigation.navigate("ThreadFile", { | 111 | { |
| 95 | environmentId: String(source.environmentId), | 112 | id: "copy-url" as const, |
| 96 | threadId: String(source.threadId), | 113 | title: "Copy URL", |
| 97 | path: reference.relativePath!.split("/"), | 114 | run: () => copyTextWithHaptic(reference.url), |
| 98 | }); | 115 | }, |
| 99 | }, | 116 | ] |
| 100 | }, | 117 | : []), |
| 101 | ] | 118 | ...(relativePath && "environmentId" in source && threadId !== undefined |
| 102 | : []), | 119 | ? [ |
| 103 | ] | 120 | { |
| 104 | : reference | 121 | id: "open-file" as const, |
| 105 | ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] | 122 | title: "Open in file viewer", |
| 123 | run: () => { | ||
| 124 | onOpenFile?.(); | ||
| 125 | navigation.navigate("ThreadFile", { | ||
| 126 | environmentId: String(source.environmentId), | ||
| 127 | threadId: String(threadId), | ||
| 128 | path: relativePath.split("/"), | ||
| 129 | }); | ||
| 130 | }, | ||
| 131 | }, | ||
| 132 | ] | ||
| 106 | : []), | 133 | : []), |
| 107 | { | 134 | { |
| 108 | id: "share", | 135 | id: "save" as const, |
| 109 | title: sharing ? "Opening share sheet…" : "Save or share", | 136 | title: sharing ? "Opening share sheet…" : "Save or share", |
| 110 | run: share, | 137 | run: share, |
| 111 | disabled: sharing, | 138 | disabled: sharing, |
| 112 | }, | 139 | }, |
| 113 | ] | 140 | ] |
| 114 | : []; | 141 | : []; |
| 115 | return { | 142 | return { |
| 116 | title: reference?.kind === "file" ? reference.path : reference?.url, | 143 | title: reference?.kind === "file" ? reference.path : reference?.url, |
| 117 | actions, | 144 | actions, |
apps/mobile/src/lib/videoPreviewSource.ts+46-17| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; | 1 | import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 2 | 3 | ||
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | 4 | import type { DraftComposerFileAttachment } from "./composerImages"; |
| 4 | import type { MediaActionsSource } from "./mediaActions"; | 5 | import type { MediaActionsSource } from "./mediaActions"; |
| ... | @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { | ... | @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { |
| 14 | | { readonly uri: string } | 15 | | { readonly uri: string } |
| 15 | | { | 16 | | { |
| 16 | readonly environmentId: EnvironmentId; | 17 | readonly environmentId: EnvironmentId; |
| 17 | readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>; | 18 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; |
| 18 | } | 19 | } |
| 19 | ); | 20 | ); |
| 20 | 21 | ||
| ... | @@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string | ... | @@ -32,23 +33,51 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string |
| 32 | return JSON.stringify( | 33 | return JSON.stringify( |
| 33 | "uri" in source | 34 | "uri" in source |
| 34 | ? ["media-video", source.uri] | 35 | ? ["media-video", source.uri] |
| 35 | : [ | 36 | : source.resource._tag === "attachment" |
| 36 | "media-video", | 37 | ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] |
| 37 | source.environmentId, | 38 | : [ |
| 38 | source.resource.threadId, | 39 | "media-video", |
| 39 | source.resource.path, | 40 | source.environmentId, |
| 40 | source.srcFragment ?? "", | 41 | source.resource.threadId, |
| 41 | ], | 42 | source.resource.path, |
| 43 | source.srcFragment ?? "", | ||
| 44 | ], | ||
| 42 | ); | 45 | ); |
| 43 | } | 46 | } |
| 44 | 47 | ||
| 45 | export type AttachmentVideoPreviewSource = ( | 48 | export interface LocalVideoPreviewSource { |
| 46 | | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } | 49 | readonly type: "local"; |
| 47 | | { | 50 | readonly attachment: DraftComposerFileAttachment; |
| 48 | readonly type: "remote"; | 51 | readonly sourceIdentifier?: string; |
| 49 | readonly environmentId: EnvironmentId; | 52 | } |
| 50 | readonly attachment: ChatFileAttachment; | 53 | |
| 51 | } | 54 | export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource; |
| 52 | ) & { readonly sourceIdentifier?: string }; | ||
| 53 | 55 | ||
| 54 | export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; | 56 | export function attachmentVideoPreviewSource( |
| 57 | environmentId: EnvironmentId, | ||
| 58 | attachment: ChatFileAttachment, | ||
| 59 | sourceIdentifier?: string, | ||
| 60 | ): MediaVideoPreviewSource { | ||
| 61 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | ||
| 62 | const resource = { | ||
| 63 | _tag: "attachment" as const, | ||
| 64 | attachmentId: attachment.id, | ||
| 65 | fileName: attachment.name, | ||
| 66 | mimeType, | ||
| 67 | }; | ||
| 68 | return { | ||
| 69 | type: "media", | ||
| 70 | name: attachment.name, | ||
| 71 | mimeType, | ||
| 72 | ...(sourceIdentifier ? { sourceIdentifier } : {}), | ||
| 73 | environmentId, | ||
| 74 | resource, | ||
| 75 | actionsSource: { | ||
| 76 | name: attachment.name, | ||
| 77 | mimeType, | ||
| 78 | ...(sourceIdentifier ? { sourceIdentifier } : {}), | ||
| 79 | environmentId, | ||
| 80 | resource, | ||
| 81 | }, | ||
| 82 | }; | ||
| 83 | } |
apps/mobile/src/state/assets.ts+17-23| ... | @@ -1,23 +1,20 @@ | ... | @@ -1,23 +1,20 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 2 | import { |
| 3 | type AssetUrlState, | ||
| 4 | assetUrlStateFromResult, | ||
| 5 | createAssetEnvironmentAtoms, | ||
| 6 | EMPTY_ASSET_URL_ATOM, | ||
| 7 | } from "@t3tools/client-runtime/state/assets"; | ||
| 3 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 8 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 5 | import { useCallback } from "react"; | 9 | import { useCallback } from "react"; |
| 6 | 10 | ||
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { usePreparedConnection } from "./session"; | 12 | import { usePreparedConnection } from "./session"; |
| 9 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | 13 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 10 | 14 | ||
| 11 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | 15 | export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; |
| 12 | |||
| 13 | const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 14 | Atom.withLabel("mobile-asset-url:empty"), | ||
| 15 | ); | ||
| 16 | 16 | ||
| 17 | export type AssetUrlState = | 17 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 18 | | { readonly _tag: "Loading" } | ||
| 19 | | { readonly _tag: "Failure" } | ||
| 20 | | { readonly _tag: "Success"; readonly url: string }; | ||
| 21 | 18 | ||
| 22 | export function useAssetUrlState( | 19 | export function useAssetUrlState( |
| 23 | environmentId: EnvironmentId | null, | 20 | environmentId: EnvironmentId | null, |
| ... | @@ -29,14 +26,10 @@ export function useAssetUrlState( | ... | @@ -29,14 +26,10 @@ export function useAssetUrlState( |
| 29 | ? EMPTY_ASSET_URL_ATOM | 26 | ? EMPTY_ASSET_URL_ATOM |
| 30 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 31 | ); | 28 | ); |
| 32 | if (result._tag === "Failure") { | 29 | return assetUrlStateFromResult( |
| 33 | return { _tag: "Failure" }; | 30 | result, |
| 34 | } | 31 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, |
| 35 | if (preparedConnection._tag === "None" || result._tag !== "Success") { | 32 | ); |
| 36 | return { _tag: "Loading" }; | ||
| 37 | } | ||
| 38 | const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | ||
| 39 | return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; | ||
| 40 | } | 33 | } |
| 41 | 34 | ||
| 42 | export function useAssetUrl( | 35 | export function useAssetUrl( |
| ... | @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( | ... | @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( |
| 60 | }); | 53 | }); |
| 61 | return useCallback(async () => { | 54 | return useCallback(async () => { |
| 62 | if (environmentId === null || resource === null || httpBaseUrl === null) return null; | 55 | if (environmentId === null || resource === null || httpBaseUrl === null) return null; |
| 63 | const result = await createUrl({ environmentId, input: { resource } }); | 56 | const state = assetUrlStateFromResult( |
| 64 | return result._tag === "Success" | 57 | await createUrl({ environmentId, input: { resource } }), |
| 65 | ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) | 58 | httpBaseUrl, |
| 66 | : null; | 59 | ); |
| 60 | return state._tag === "Success" ? state.url : null; | ||
| 67 | }, [createUrl, environmentId, httpBaseUrl, resource]); | 61 | }, [createUrl, environmentId, httpBaseUrl, resource]); |
| 68 | } | 62 | } |
apps/web/src/assets/assetUrls.ts+24-35| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 2 | import { |
| 3 | type AssetUrlState, | ||
| 4 | assetUrlStateFromResult, | ||
| 5 | EMPTY_ASSET_URL_ATOM, | ||
| 6 | resolveAssetUrl, | ||
| 7 | } from "@t3tools/client-runtime/state/assets"; | ||
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 9 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| ... | @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; | ... | @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; |
| 9 | import { usePreparedConnection } from "~/state/session"; | 14 | import { usePreparedConnection } from "~/state/session"; |
| 10 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 15 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 11 | 16 | ||
| 12 | export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 17 | export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; |
| 13 | |||
| 14 | export type AssetUrlState = | ||
| 15 | | { readonly _tag: "Loading" } | ||
| 16 | | { readonly _tag: "Failure" } | ||
| 17 | | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; | ||
| 18 | 18 | ||
| 19 | export function useAssetUrlState( | 19 | export function useAssetUrlState( |
| 20 | environmentId: EnvironmentId, | 20 | environmentId: EnvironmentId | null, |
| 21 | resource: AssetResource, | 21 | resource: AssetResource | null, |
| 22 | ): AssetUrlState { | 22 | ): AssetUrlState { |
| 23 | const preparedConnection = usePreparedConnection(environmentId); | 23 | const preparedConnection = usePreparedConnection(environmentId); |
| 24 | const result = useAtomValue( | 24 | const result = useAtomValue( |
| 25 | assetEnvironment.createUrl({ | 25 | environmentId === null || resource === null |
| 26 | environmentId, | 26 | ? EMPTY_ASSET_URL_ATOM |
| 27 | input: { resource }, | 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 28 | }), | 28 | ); |
| 29 | return assetUrlStateFromResult( | ||
| 30 | result, | ||
| 31 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | ||
| 29 | ); | 32 | ); |
| 30 | if (result._tag === "Failure") { | ||
| 31 | return { _tag: "Failure" }; | ||
| 32 | } | ||
| 33 | if (preparedConnection._tag === "None" || result._tag !== "Success") { | ||
| 34 | return { _tag: "Loading" }; | ||
| 35 | } | ||
| 36 | const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | ||
| 37 | return url === null | ||
| 38 | ? { _tag: "Failure" } | ||
| 39 | : { | ||
| 40 | _tag: "Success", | ||
| 41 | url, | ||
| 42 | ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), | ||
| 43 | }; | ||
| 44 | } | 33 | } |
| 45 | 34 | ||
| 46 | export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { | 35 | export function useAssetUrl( |
| 36 | environmentId: EnvironmentId | null, | ||
| 37 | resource: AssetResource | null, | ||
| 38 | ): string | null { | ||
| 47 | const result = useAssetUrlState(environmentId, resource); | 39 | const result = useAssetUrlState(environmentId, resource); |
| 48 | if (result._tag !== "Success") { | 40 | return result._tag === "Success" ? result.url : null; |
| 49 | return null; | ||
| 50 | } | ||
| 51 | return result.url; | ||
| 52 | } | 41 | } |
| 53 | 42 | ||
| 54 | /** Re-mints an exact-file capability after a file change or an explicit retry. */ | ||
| 55 | export function useAssetUrlRefresh( | 43 | export function useAssetUrlRefresh( |
| 56 | environmentId: EnvironmentId, | 44 | environmentId: EnvironmentId | null, |
| 57 | resource: AssetResource, | 45 | resource: AssetResource | null, |
| 58 | ): () => Promise<void> { | 46 | ): () => Promise<void> { |
| 59 | const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { | 47 | const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 60 | reportFailure: false, | 48 | reportFailure: false, |
| 61 | refresh: true, | 49 | refresh: true, |
| 62 | }); | 50 | }); |
| 63 | return useCallback(async () => { | 51 | return useCallback(async () => { |
| 52 | if (environmentId === null || resource === null) return; | ||
| 64 | const result = await refresh({ environmentId, input: { resource } }); | 53 | const result = await refresh({ environmentId, input: { resource } }); |
| 65 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | 54 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 66 | }, [environmentId, resource, refresh]); | 55 | }, [environmentId, resource, refresh]); |
apps/web/src/components/ChatMarkdown.tsx-24| ... | @@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: { | ... | @@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: { |
| 1274 | readonly mediaIdentity?: string | undefined; | 1274 | readonly mediaIdentity?: string | undefined; |
| 1275 | readonly actionsSource?: MediaActionSource | undefined; | 1275 | readonly actionsSource?: MediaActionSource | undefined; |
| 1276 | readonly onRetry?: (() => Promise<void>) | undefined; | 1276 | readonly onRetry?: (() => Promise<void>) | undefined; |
| 1277 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; | ||
| 1278 | }) { | 1277 | }) { |
| 1279 | return ( | 1278 | return ( |
| 1280 | <MediaVideoPlayer | 1279 | <MediaVideoPlayer |
| ... | @@ -1296,27 +1295,6 @@ function ChatMarkdownVideo(props: { | ... | @@ -1296,27 +1295,6 @@ function ChatMarkdownVideo(props: { |
| 1296 | )} | 1295 | )} |
| 1297 | onRetry={props.onRetry} | 1296 | onRetry={props.onRetry} |
| 1298 | actionsSource={props.actionsSource} | 1297 | actionsSource={props.actionsSource} |
| 1299 | onExpand={ | ||
| 1300 | props.onImageExpand | ||
| 1301 | ? (src) => { | ||
| 1302 | props.onImageExpand?.({ | ||
| 1303 | images: [ | ||
| 1304 | { | ||
| 1305 | src, | ||
| 1306 | name: props.alt || "video", | ||
| 1307 | type: "video", | ||
| 1308 | autoPlay: false, | ||
| 1309 | ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), | ||
| 1310 | ...(props.actionsSource | ||
| 1311 | ? { actionsSource: { ...props.actionsSource, src } } | ||
| 1312 | : {}), | ||
| 1313 | }, | ||
| 1314 | ], | ||
| 1315 | index: 0, | ||
| 1316 | }); | ||
| 1317 | } | ||
| 1318 | : undefined | ||
| 1319 | } | ||
| 1320 | /> | 1298 | /> |
| 1321 | ); | 1299 | ); |
| 1322 | } | 1300 | } |
| ... | @@ -1378,7 +1356,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props | ... | @@ -1378,7 +1356,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1378 | style={props.style} | 1356 | style={props.style} |
| 1379 | mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} | 1357 | mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1380 | onRetry={refreshAssetUrl} | 1358 | onRetry={refreshAssetUrl} |
| 1381 | onImageExpand={props.onImageExpand} | ||
| 1382 | actionsSource={actionsSource} | 1359 | actionsSource={actionsSource} |
| 1383 | /> | 1360 | /> |
| 1384 | ); | 1361 | ); |
| ... | @@ -2642,7 +2619,6 @@ function ChatMarkdown({ | ... | @@ -2642,7 +2619,6 @@ function ChatMarkdown({ |
| 2642 | copyMarkdown={copyMarkdown} | 2619 | copyMarkdown={copyMarkdown} |
| 2643 | originalUrl={originalUrl} | 2620 | originalUrl={originalUrl} |
| 2644 | style={authoredSizeStyle} | 2621 | style={authoredSizeStyle} |
| 2645 | onImageExpand={imageExpand} | ||
| 2646 | actionsSource={actionsSource} | 2622 | actionsSource={actionsSource} |
| 2647 | /> | 2623 | /> |
| 2648 | ); | 2624 | ); |
apps/web/src/components/ChatView.logic.test.ts-9| ... | @@ -24,7 +24,6 @@ import { | ... | @@ -24,7 +24,6 @@ import { |
| 24 | dismissBranchMismatchForSession, | 24 | dismissBranchMismatchForSession, |
| 25 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, | 25 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, |
| 26 | getStartedThreadModelChangeBlockReason, | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | isVideoPreviewRequestCurrent, | ||
| 28 | hasEnvironmentReconnectWarningGraceElapsed, | 27 | hasEnvironmentReconnectWarningGraceElapsed, |
| 29 | hasServerAcknowledgedLocalDispatch, | 28 | hasServerAcknowledgedLocalDispatch, |
| 30 | isBranchMismatchDismissedForSession, | 29 | isBranchMismatchDismissedForSession, |
| ... | @@ -130,14 +129,6 @@ describe("proactive panels", () => { | ... | @@ -130,14 +129,6 @@ describe("proactive panels", () => { |
| 130 | }); | 129 | }); |
| 131 | }); | 130 | }); |
| 132 | 131 | ||
| 133 | describe("isVideoPreviewRequestCurrent", () => { | ||
| 134 | it("rejects changed threads and replaced previews", () => { | ||
| 135 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); | ||
| 136 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); | ||
| 137 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | |||
| 141 | describe("toolGroupConsumesUpwardNavigation", () => { | 132 | describe("toolGroupConsumesUpwardNavigation", () => { |
| 142 | class ScrollElement extends EventTarget { | 133 | class ScrollElement extends EventTarget { |
| 143 | scrollTop = 0; | 134 | scrollTop = 0; |
apps/web/src/components/ChatView.logic.ts-9| ... | @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { | ... | @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { |
| 399 | return url; | 399 | return url; |
| 400 | } | 400 | } |
| 401 | 401 | ||
| 402 | export function isVideoPreviewRequestCurrent( | ||
| 403 | requestThreadKey: string, | ||
| 404 | currentThreadKey: string, | ||
| 405 | requestId: number, | ||
| 406 | currentRequestId: number, | ||
| 407 | ): boolean { | ||
| 408 | return requestThreadKey === currentThreadKey && requestId === currentRequestId; | ||
| 409 | } | ||
| 410 | |||
| 411 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { | 402 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { |
| 412 | if (message.role !== "user" || !message.attachments) { | 403 | if (message.role !== "user" || !message.attachments) { |
| 413 | return; | 404 | return; |
apps/web/src/components/ChatView.tsx+12-53| ... | @@ -135,7 +135,6 @@ import { | ... | @@ -135,7 +135,6 @@ import { |
| 135 | type ChatMessage, | 135 | type ChatMessage, |
| 136 | isBrowserPreviewAttachment, | 136 | isBrowserPreviewAttachment, |
| 137 | isImageAttachment, | 137 | isImageAttachment, |
| 138 | videoMimeType, | ||
| 139 | type SessionPhase, | 138 | type SessionPhase, |
| 140 | type Thread, | 139 | type Thread, |
| 141 | type TurnDiffSummary, | 140 | type TurnDiffSummary, |
| ... | @@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; | ... | @@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 300 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; | 299 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; |
| 301 | import { ChatHeader } from "./chat/ChatHeader"; | 300 | import { ChatHeader } from "./chat/ChatHeader"; |
| 302 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 301 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 303 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 302 | import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 304 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 303 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 305 | import { WorkspacePageHeader } from "./WorkspacePageHeader"; | 304 | import { WorkspacePageHeader } from "./WorkspacePageHeader"; |
| 306 | import { | 305 | import { |
| ... | @@ -375,7 +374,6 @@ import { | ... | @@ -375,7 +374,6 @@ import { |
| 375 | deriveLockedProvider, | 374 | deriveLockedProvider, |
| 376 | readFileAsDataUrl, | 375 | readFileAsDataUrl, |
| 377 | resolveFileAttachmentUrl, | 376 | resolveFileAttachmentUrl, |
| 378 | isVideoPreviewRequestCurrent, | ||
| 379 | reconcileMountedTerminalThreadIds, | 377 | reconcileMountedTerminalThreadIds, |
| 380 | resolveBackgroundDraftWorkspaceOptions, | 378 | resolveBackgroundDraftWorkspaceOptions, |
| 381 | resolveDraftHeroState, | 379 | resolveDraftHeroState, |
| ... | @@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 1509 | [composerRef], | 1507 | [composerRef], |
| 1510 | ); | 1508 | ); |
| 1511 | const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); | 1509 | const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); |
| 1512 | const routeThreadKeyRef = useRef(routeThreadKey); | ||
| 1513 | routeThreadKeyRef.current = routeThreadKey; | ||
| 1514 | const videoPreviewRequestIdRef = useRef(0); | ||
| 1515 | const cancelVideoPreviewRequest = useCallback(() => { | ||
| 1516 | videoPreviewRequestIdRef.current += 1; | ||
| 1517 | }, []); | ||
| 1518 | const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null); | ||
| 1519 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); | 1510 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 1520 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 1511 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 1521 | useEffect(() => { | 1512 | useEffect(() => { |
| 1522 | const item = expandedImage?.images[expandedImage.index]; | 1513 | const item = expandedImage?.images[expandedImage.index]; |
| 1523 | if (item?.type !== "video" || !item.src.startsWith("blob:")) return; | 1514 | if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; |
| 1524 | return () => revokeBlobPreviewUrl(item.src); | 1515 | const src = item.src; |
| 1516 | return () => revokeBlobPreviewUrl(src); | ||
| 1525 | }, [expandedImage]); | 1517 | }, [expandedImage]); |
| 1526 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); | 1518 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 1527 | const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< | 1519 | const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< |
| ... | @@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 2575 | useEffect(() => { | 2567 | useEffect(() => { |
| 2576 | return () => { | 2568 | return () => { |
| 2577 | clearAttachmentPreviewHandoffs(); | 2569 | clearAttachmentPreviewHandoffs(); |
| 2578 | cancelVideoPreviewRequest(); | ||
| 2579 | for (const message of optimisticUserMessagesRef.current) { | 2570 | for (const message of optimisticUserMessagesRef.current) { |
| 2580 | revokeUserMessagePreviewUrls(message); | 2571 | revokeUserMessagePreviewUrls(message); |
| 2581 | } | 2572 | } |
| 2582 | }; | 2573 | }; |
| 2583 | }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); | 2574 | }, [clearAttachmentPreviewHandoffs]); |
| 2584 | const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { | 2575 | const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { |
| 2585 | if (previewUrls.length === 0) return; | 2576 | if (previewUrls.length === 0) return; |
| 2586 | 2577 | ||
| ... | @@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2608 | toastManager.add({ type: "error", title: "The environment is not connected." }); | 2599 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2609 | return; | 2600 | return; |
| 2610 | } | 2601 | } |
| 2611 | const isVideo = videoMimeType(attachment) !== null; | ||
| 2612 | const action = isVideo ? "play" : "download"; | ||
| 2613 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; | ||
| 2614 | const isCurrentRequest = () => | ||
| 2615 | !isVideo || | ||
| 2616 | isVideoPreviewRequestCurrent( | ||
| 2617 | routeThreadKey, | ||
| 2618 | routeThreadKeyRef.current, | ||
| 2619 | videoPreviewRequestId, | ||
| 2620 | videoPreviewRequestIdRef.current, | ||
| 2621 | ); | ||
| 2622 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); | ||
| 2623 | 2602 | ||
| 2624 | try { | 2603 | try { |
| 2625 | const url = await resolveFileAttachmentUrl({ | 2604 | const url = await resolveFileAttachmentUrl({ |
| ... | @@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) { |
| 2628 | httpBaseUrl: connection.httpBaseUrl, | 2607 | httpBaseUrl: connection.httpBaseUrl, |
| 2629 | createAssetUrl: createAttachmentAssetUrl, | 2608 | createAssetUrl: createAttachmentAssetUrl, |
| 2630 | }); | 2609 | }); |
| 2631 | if (!isCurrentRequest()) return; | ||
| 2632 | if (isVideo) { | ||
| 2633 | setExpandedImage({ | ||
| 2634 | images: [{ src: url, name: attachment.name, type: "video" }], | ||
| 2635 | index: 0, | ||
| 2636 | }); | ||
| 2637 | return; | ||
| 2638 | } | ||
| 2639 | const anchor = document.createElement("a"); | 2610 | const anchor = document.createElement("a"); |
| 2640 | anchor.href = url; | 2611 | anchor.href = url; |
| 2641 | anchor.download = attachment.name; | 2612 | anchor.download = attachment.name; |
| 2642 | anchor.click(); | 2613 | anchor.click(); |
| 2643 | } catch (error) { | 2614 | } catch (error) { |
| 2644 | if (!isCurrentRequest()) return; | ||
| 2645 | toastManager.add({ | 2615 | toastManager.add({ |
| 2646 | type: "error", | 2616 | type: "error", |
| 2647 | title: "Could not " + action + " " + attachment.name, | 2617 | title: "Could not download " + attachment.name, |
| 2648 | description: error instanceof Error ? error.message : "The attachment is unavailable.", | 2618 | description: error instanceof Error ? error.message : "The attachment is unavailable.", |
| 2649 | }); | 2619 | }); |
| 2650 | } finally { | ||
| 2651 | if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); | ||
| 2652 | } | 2620 | } |
| 2653 | }, | 2621 | }, |
| 2654 | [createAttachmentAssetUrl, environmentId, routeThreadKey], | 2622 | [createAttachmentAssetUrl, environmentId], |
| 2655 | ); | 2623 | ); |
| 2656 | const openFileAttachment = useCallback( | 2624 | const openFileAttachment = useCallback( |
| 2657 | (attachment: ChatFileAttachment) => { | 2625 | (attachment: ChatFileAttachment) => { |
| ... | @@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4690 | return []; | 4658 | return []; |
| 4691 | }); | 4659 | }); |
| 4692 | resetLocalDispatch(); | 4660 | resetLocalDispatch(); |
| 4693 | cancelVideoPreviewRequest(); | ||
| 4694 | setOpeningVideoAttachmentId(null); | ||
| 4695 | setExpandedImage(null); | 4661 | setExpandedImage(null); |
| 4696 | }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); | 4662 | }, [draftId, resetLocalDispatch, threadId]); |
| 4697 | 4663 | ||
| 4698 | const closeExpandedImage = useCallback(() => { | 4664 | const closeExpandedImage = useCallback(() => { |
| 4699 | setExpandedImage(null); | 4665 | setExpandedImage(null); |
| ... | @@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 7188 | } | 7154 | } |
| 7189 | }; | 7155 | }; |
| 7190 | 7156 | ||
| 7191 | const onExpandTimelineImage = useCallback( | 7157 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { |
| 7192 | (preview: ExpandedImagePreview) => { | 7158 | setExpandedImage(preview); |
| 7193 | cancelVideoPreviewRequest(); | 7159 | }, []); |
| 7194 | setOpeningVideoAttachmentId(null); | ||
| 7195 | setExpandedImage(preview); | ||
| 7196 | }, | ||
| 7197 | [cancelVideoPreviewRequest], | ||
| 7198 | ); | ||
| 7199 | const onOpenTurnDiff = useCallback( | 7160 | const onOpenTurnDiff = useCallback( |
| 7200 | (turnId: TurnId, filePath?: string) => { | 7161 | (turnId: TurnId, filePath?: string) => { |
| 7201 | if (!isServerThread || !activeThreadRef) return; | 7162 | if (!isServerThread || !activeThreadRef) return; |
| ... | @@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 7525 | onImageExpand={onExpandTimelineImage} | 7486 | onImageExpand={onExpandTimelineImage} |
| 7526 | onFileOpen={openFileAttachment} | 7487 | onFileOpen={openFileAttachment} |
| 7527 | onFileDownload={downloadFileAttachment} | 7488 | onFileDownload={downloadFileAttachment} |
| 7528 | openingVideoAttachmentId={openingVideoAttachmentId} | ||
| 7529 | markdownCwd={gitCwd ?? undefined} | 7489 | markdownCwd={gitCwd ?? undefined} |
| 7530 | resolvedTheme={resolvedTheme} | 7490 | resolvedTheme={resolvedTheme} |
| 7531 | timestampFormat={timestampFormat} | 7491 | timestampFormat={timestampFormat} |
| ... | @@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 7701 | setThreadError={setThreadError} | 7661 | setThreadError={setThreadError} |
| 7702 | onExpandImage={onExpandTimelineImage} | 7662 | onExpandImage={onExpandTimelineImage} |
| 7703 | onFileOpen={openFileAttachment} | 7663 | onFileOpen={openFileAttachment} |
| 7704 | openingVideoAttachmentId={openingVideoAttachmentId} | ||
| 7705 | /> | 7664 | /> |
| 7706 | </div> | 7665 | </div> |
| 7707 | </ComposerSurface.Host> | 7666 | </ComposerSurface.Host> |
| ... | @@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7922 | 7881 | ||
| 7923 | {expandedImage && ( | 7882 | {expandedImage && ( |
| 7924 | <ExpandedImageDialog | 7883 | <ExpandedImageDialog |
| 7925 | key={`${expandedImage.images[expandedImage.index]?.src ?? "image"}:${expandedImage.index}`} | 7884 | key={expandedImageKey(expandedImage)} |
| 7926 | preview={expandedImage} | 7885 | preview={expandedImage} |
| 7927 | onClose={closeExpandedImage} | 7886 | onClose={closeExpandedImage} |
| 7928 | /> | 7887 | /> |
apps/web/src/components/chat/ChatComposer.tsx+3-13| ... | @@ -741,7 +741,6 @@ export interface ChatComposerProps { | ... | @@ -741,7 +741,6 @@ export interface ChatComposerProps { |
| 741 | setThreadError: (threadId: ThreadId | null, error: string | null) => void; | 741 | setThreadError: (threadId: ThreadId | null, error: string | null) => void; |
| 742 | onExpandImage: (preview: ExpandedImagePreview) => void; | 742 | onExpandImage: (preview: ExpandedImagePreview) => void; |
| 743 | onFileOpen: (attachment: ChatFileAttachment) => void; | 743 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 744 | openingVideoAttachmentId: string | null; | ||
| 745 | } | 744 | } |
| 746 | 745 | ||
| 747 | // -------------------------------------------------------------------------- | 746 | // -------------------------------------------------------------------------- |
| ... | @@ -820,7 +819,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -820,7 +819,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 820 | setThreadError, | 819 | setThreadError, |
| 821 | onExpandImage, | 820 | onExpandImage, |
| 822 | onFileOpen, | 821 | onFileOpen, |
| 823 | openingVideoAttachmentId, | ||
| 824 | } = props; | 822 | } = props; |
| 825 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; | 823 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; |
| 826 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; | 824 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; |
| ... | @@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4020 | maxFileAttachmentBytes !== null && | 4018 | maxFileAttachmentBytes !== null && |
| 4021 | file.sizeBytes <= maxFileAttachmentBytes; | 4019 | file.sizeBytes <= maxFileAttachmentBytes; |
| 4022 | const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; | 4020 | const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; |
| 4023 | const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId; | ||
| 4024 | return ( | 4021 | return ( |
| 4025 | <div | 4022 | <div |
| 4026 | key={file.id} | 4023 | key={file.id} |
| ... | @@ -4028,12 +4025,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4028,12 +4025,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4028 | > | 4025 | > |
| 4029 | <button | 4026 | <button |
| 4030 | type="button" | 4027 | type="button" |
| 4031 | className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white aria-disabled:cursor-default aria-disabled:opacity-50" | 4028 | className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white" |
| 4032 | aria-busy={isOpening || undefined} | 4029 | aria-label={`Play ${file.name}`} |
| 4033 | aria-disabled={isOpening || undefined} | ||
| 4034 | aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`} | ||
| 4035 | onClick={() => { | 4030 | onClick={() => { |
| 4036 | if (isOpening) return; | ||
| 4037 | if (file.file !== null) { | 4031 | if (file.file !== null) { |
| 4038 | const preview = buildExpandedImagePreview([file], file.id); | 4032 | const preview = buildExpandedImagePreview([file], file.id); |
| 4039 | if (preview) onExpandImage(preview); | 4033 | if (preview) onExpandImage(preview); |
| ... | @@ -4049,11 +4043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4049,11 +4043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4049 | <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" /> | 4043 | <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" /> |
| 4050 | </> | 4044 | </> |
| 4051 | )} | 4045 | )} |
| 4052 | {isOpening ? ( | 4046 | <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" /> |
| 4053 | <span className="relative z-10 text-[10px]">Loading…</span> | ||
| 4054 | ) : ( | ||
| 4055 | <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" /> | ||
| 4056 | )} | ||
| 4057 | </button> | 4047 | </button> |
| 4058 | {upload?.status === "uploading" && ( | 4048 | {upload?.status === "uploading" && ( |
| 4059 | <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground"> | 4049 | <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground"> |
apps/web/src/components/chat/ExpandedImageDialog.tsx+35-25| ... | @@ -2,11 +2,12 @@ import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; | ... | @@ -2,11 +2,12 @@ import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; |
| 2 | import { createPortal } from "react-dom"; | 2 | import { createPortal } from "react-dom"; |
| 3 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; | 3 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; |
| 4 | import { Button } from "../ui/button"; | 4 | import { Button } from "../ui/button"; |
| 5 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | 5 | import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 6 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | ||
| 7 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; | 6 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 7 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | ||
| 8 | import { OpenMediaLink } from "../media/OpenMediaLink"; | 8 | import { OpenMediaLink } from "../media/OpenMediaLink"; |
| 9 | import { MediaActions, type MediaActionSource } from "../media/MediaActions"; | 9 | import { MediaActions, type MediaActionSource } from "../media/MediaActions"; |
| 10 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | ||
| 10 | import { isContextMenuOpen } from "../../contextMenuFallback"; | 11 | import { isContextMenuOpen } from "../../contextMenuFallback"; |
| 11 | 12 | ||
| 12 | interface ExpandedImageDialogProps { | 13 | interface ExpandedImageDialogProps { |
| ... | @@ -14,23 +15,47 @@ interface ExpandedImageDialogProps { | ... | @@ -14,23 +15,47 @@ interface ExpandedImageDialogProps { |
| 14 | onClose: () => void; | 15 | onClose: () => void; |
| 15 | } | 16 | } |
| 16 | 17 | ||
| 18 | const EXPANDED_MEDIA_STATE_CLASS_NAME = | ||
| 19 | "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"; | ||
| 20 | |||
| 17 | function ExpandedMediaFailure({ children }: { children: ReactNode }) { | 21 | function ExpandedMediaFailure({ children }: { children: ReactNode }) { |
| 18 | return ( | 22 | return ( |
| 19 | <div | 23 | <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}> |
| 20 | role="alert" | ||
| 21 | className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-sm text-white shadow-2xl" | ||
| 22 | > | ||
| 23 | {children} | 24 | {children} |
| 24 | </div> | 25 | </div> |
| 25 | ); | 26 | ); |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 29 | function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { | ||
| 30 | const asset = item.actionsSource?.asset; | ||
| 31 | const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); | ||
| 32 | const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); | ||
| 33 | const src = asset | ||
| 34 | ? assetUrl._tag === "Success" | ||
| 35 | ? assetUrl.url + (item.srcFragment ?? "") | ||
| 36 | : null | ||
| 37 | : item.src; | ||
| 38 | return ( | ||
| 39 | <MediaVideoPlayer | ||
| 40 | src={src} | ||
| 41 | label={item.name} | ||
| 42 | sourceFailed={assetUrl._tag === "Failure"} | ||
| 43 | originalUrl={item.originalUrl} | ||
| 44 | preload="metadata" | ||
| 45 | autoPlay={item.autoPlay ?? true} | ||
| 46 | className="block max-h-[86vh] max-w-[92vw] text-center" | ||
| 47 | videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl" | ||
| 48 | stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} | ||
| 49 | onRetry={asset ? refreshAssetUrl : undefined} | ||
| 50 | /> | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 28 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | 54 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 29 | preview, | 55 | preview, |
| 30 | onClose, | 56 | onClose, |
| 31 | }: ExpandedImageDialogProps) { | 57 | }: ExpandedImageDialogProps) { |
| 32 | const [imageOffset, setImageOffset] = useState(0); | 58 | const [imageOffset, setImageOffset] = useState(0); |
| 33 | const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null); | ||
| 34 | const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); | 59 | const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); |
| 35 | const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; | 60 | const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; |
| 36 | const item = preview.images[index]; | 61 | const item = preview.images[index]; |
| ... | @@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | ... | @@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 125 | > | 150 | > |
| 126 | <XIcon /> | 151 | <XIcon /> |
| 127 | </Button> | 152 | </Button> |
| 128 | {item.type === "video" && failedVideoSrc === item.src ? ( | 153 | {item.type === "video" ? ( |
| 129 | <ExpandedMediaFailure> | 154 | <ExpandedVideo key={index} item={item} /> |
| 130 | <p>This video could not be loaded or played.</p> | 155 | ) : item.src === null || failedImageSrc === item.src ? ( |
| 131 | <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} /> | ||
| 132 | </ExpandedMediaFailure> | ||
| 133 | ) : item.type === "video" ? ( | ||
| 134 | <video | ||
| 135 | src={item.src} | ||
| 136 | aria-label={item.name} | ||
| 137 | autoPlay={item.autoPlay ?? true} | ||
| 138 | preload="metadata" | ||
| 139 | controls | ||
| 140 | playsInline | ||
| 141 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} | ||
| 142 | onError={() => setFailedVideoSrc(item.src)} | ||
| 143 | className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl" | ||
| 144 | /> | ||
| 145 | ) : failedImageSrc === item.src ? ( | ||
| 146 | <ExpandedMediaFailure> | 156 | <ExpandedMediaFailure> |
| 147 | <p> | 157 | <p> |
| 148 | {openOriginalLink | 158 | {openOriginalLink |
apps/web/src/components/chat/ExpandedImagePreview.tsx+45-45| ... | @@ -3,31 +3,30 @@ import { type ChatImageAttachment, isVideoAttachment } from "../../types"; | ... | @@ -3,31 +3,30 @@ import { type ChatImageAttachment, isVideoAttachment } from "../../types"; |
| 3 | import type { | 3 | import type { |
| 4 | AssetCreateUrlResult, | 4 | AssetCreateUrlResult, |
| 5 | AssetResource, | 5 | AssetResource, |
| 6 | ChatFileAttachment, | ||
| 6 | EnvironmentId, | 7 | EnvironmentId, |
| 7 | ScopedThreadRef, | 8 | ScopedThreadRef, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { | 10 | import { videoMimeType } from "@t3tools/shared/video"; |
| 10 | classifyMarkdownImageSource, | 11 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 11 | markdownImageSourceFragment, | ||
| 12 | } from "@t3tools/client-runtime/markdown-images"; | ||
| 13 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | ||
| 14 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 12 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 15 | import { | 13 | import { |
| 16 | squashAtomCommandFailure, | 14 | squashAtomCommandFailure, |
| 17 | type AtomCommandResult, | 15 | type AtomCommandResult, |
| 18 | } from "@t3tools/client-runtime/state/runtime"; | 16 | } from "@t3tools/client-runtime/state/runtime"; |
| 19 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | ||
| 20 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; | 17 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 21 | import type { MediaActionSource } from "../media/MediaActions"; | 18 | import type { MediaActionSource } from "../media/MediaActions"; |
| 22 | import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; | 19 | import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; |
| 23 | 20 | ||
| 24 | export interface ExpandedImageItem { | 21 | export interface ExpandedImageItem { |
| 25 | src: string; | 22 | /** A loadable URL, or null when the dialog must mint one from `asset` first. */ |
| 23 | src: string | null; | ||
| 26 | name: string; | 24 | name: string; |
| 27 | type?: "video"; | 25 | type?: "video"; |
| 28 | autoPlay?: boolean; | 26 | autoPlay?: boolean; |
| 29 | /** Authored remote destination to open when embedding fails, never a generated asset URL. */ | 27 | /** Authored remote destination to open when embedding fails, never a generated asset URL. */ |
| 30 | originalUrl?: string; | 28 | originalUrl?: string; |
| 29 | srcFragment?: string; | ||
| 31 | actionsSource?: MediaActionSource; | 30 | actionsSource?: MediaActionSource; |
| 32 | } | 31 | } |
| 33 | 32 | ||
| ... | @@ -49,45 +48,24 @@ export async function resolveMarkdownMediaPreview(input: { | ... | @@ -49,45 +48,24 @@ export async function resolveMarkdownMediaPreview(input: { |
| 49 | input: { resource: AssetResource }; | 48 | input: { resource: AssetResource }; |
| 50 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>; | 49 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>; |
| 51 | }): Promise<ExpandedImagePreview | null> { | 50 | }): Promise<ExpandedImagePreview | null> { |
| 52 | const source = | 51 | const media = resolveMediaSource(input.source, { |
| 53 | input.resolvedFilePath === undefined | 52 | threadId: input.threadRef?.threadId, |
| 54 | ? classifyMarkdownImageSource(input.source, input.cwd) | 53 | workspaceRoot: input.cwd, |
| 55 | : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath }; | 54 | resolvedFilePath: input.resolvedFilePath, |
| 56 | if (source._tag === "Blocked") return null; | 55 | }); |
| 57 | 56 | if (media === null) return null; | |
| 58 | const path = | 57 | const { kind, name, reference } = media; |
| 59 | source._tag === "Direct" | ||
| 60 | ? source.uri.split(/[?#]/, 1)[0]! | ||
| 61 | : source.path.replace(/:\d+(?::\d+)?$/, ""); | ||
| 62 | const name = path.split(/[\\/]/).at(-1) ?? ""; | ||
| 63 | const extensionIndex = name.lastIndexOf("."); | ||
| 64 | const fileMimeType = | ||
| 65 | extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex)); | ||
| 66 | const kind = | ||
| 67 | source._tag === "Direct" | ||
| 68 | ? mediaKindFromPath(source.uri) | ||
| 69 | : fileMimeType === null | ||
| 70 | ? null | ||
| 71 | : fileMimeType.startsWith("video/") | ||
| 72 | ? "video" | ||
| 73 | : "image"; | ||
| 74 | if (kind === null) return null; | ||
| 75 | |||
| 76 | const reference = | ||
| 77 | source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd); | ||
| 78 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; | 58 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; |
| 59 | |||
| 79 | let src: string; | 60 | let src: string; |
| 80 | let asset: MediaActionSource["asset"]; | 61 | let asset: MediaActionSource["asset"]; |
| 81 | if (source._tag === "Direct") { | 62 | if (media.access === "direct") { |
| 82 | src = resolveProtocolRelativeMediaUrl(source.uri); | 63 | src = resolveProtocolRelativeMediaUrl(media.uri); |
| 83 | } else { | 64 | } else { |
| 84 | if (!input.threadRef || !input.httpBaseUrl) { | 65 | if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) { |
| 85 | throw new Error("Reconnect to this environment and open the media again."); | 66 | throw new Error("Reconnect to this environment and open the media again."); |
| 86 | } | 67 | } |
| 87 | asset = { | 68 | asset = { environmentId: input.threadRef.environmentId, resource: media.resource }; |
| 88 | environmentId: input.threadRef.environmentId, | ||
| 89 | resource: { _tag: "media-file", threadId: input.threadRef.threadId, path }, | ||
| 90 | }; | ||
| 91 | const result = await input.createAssetUrl({ | 69 | const result = await input.createAssetUrl({ |
| 92 | environmentId: asset.environmentId, | 70 | environmentId: asset.environmentId, |
| 93 | input: { resource: asset.resource }, | 71 | input: { resource: asset.resource }, |
| ... | @@ -95,20 +73,21 @@ export async function resolveMarkdownMediaPreview(input: { | ... | @@ -95,20 +73,21 @@ export async function resolveMarkdownMediaPreview(input: { |
| 95 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | 73 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 96 | const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); | 74 | const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); |
| 97 | if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); | 75 | if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); |
| 98 | src = assetUrl + markdownImageSourceFragment(input.source); | 76 | src = assetUrl + media.srcFragment; |
| 99 | } | 77 | } |
| 100 | return { | 78 | return { |
| 101 | images: [ | 79 | images: [ |
| 102 | { | 80 | { |
| 103 | src, | 81 | src, |
| 104 | name: name || kind, | 82 | name, |
| 105 | ...(kind === "video" ? { type: "video", autoPlay: false } : {}), | 83 | ...(kind === "video" ? { type: "video", autoPlay: false } : {}), |
| 106 | ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null | 84 | ...(media.access === "direct" && resolveExternalWebLinkHost(media.uri) !== null |
| 107 | ? { originalUrl: source.uri } | 85 | ? { originalUrl: media.uri } |
| 108 | : {}), | 86 | : {}), |
| 87 | ...(media.srcFragment ? { srcFragment: media.srcFragment } : {}), | ||
| 109 | actionsSource: { | 88 | actionsSource: { |
| 110 | kind, | 89 | kind, |
| 111 | name: name || kind, | 90 | name, |
| 112 | src, | 91 | src, |
| 113 | ...(reference ? { reference } : {}), | 92 | ...(reference ? { reference } : {}), |
| 114 | ...(asset ? { asset } : {}), | 93 | ...(asset ? { asset } : {}), |
| ... | @@ -122,6 +101,27 @@ export async function resolveMarkdownMediaPreview(input: { | ... | @@ -122,6 +101,27 @@ export async function resolveMarkdownMediaPreview(input: { |
| 122 | }; | 101 | }; |
| 123 | } | 102 | } |
| 124 | 103 | ||
| 104 | export function buildAttachmentVideoAsset( | ||
| 105 | environmentId: EnvironmentId, | ||
| 106 | attachment: ChatFileAttachment, | ||
| 107 | ): NonNullable<MediaActionSource["asset"]> { | ||
| 108 | return { | ||
| 109 | environmentId, | ||
| 110 | resource: { | ||
| 111 | _tag: "attachment" as const, | ||
| 112 | attachmentId: attachment.id, | ||
| 113 | fileName: attachment.name, | ||
| 114 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, | ||
| 115 | }, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | export function expandedImageKey(preview: ExpandedImagePreview): string { | ||
| 120 | const item = preview.images[preview.index]; | ||
| 121 | const asset = item?.actionsSource?.asset; | ||
| 122 | return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource]) : "image")}:${preview.index}`; | ||
| 123 | } | ||
| 124 | |||
| 125 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { | 125 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { |
| 126 | const url = URL.createObjectURL(file); | 126 | const url = URL.createObjectURL(file); |
| 127 | video.src = url; | 127 | video.src = url; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+31-16| ... | @@ -147,7 +147,6 @@ function buildProps() { | ... | @@ -147,7 +147,6 @@ function buildProps() { |
| 147 | revertTurnCountByUserMessageId: new Map(), | 147 | revertTurnCountByUserMessageId: new Map(), |
| 148 | onRevertUserMessage: () => {}, | 148 | onRevertUserMessage: () => {}, |
| 149 | isRevertingCheckpoint: false, | 149 | isRevertingCheckpoint: false, |
| 150 | openingVideoAttachmentId: null, | ||
| 151 | onImageExpand: () => {}, | 150 | onImageExpand: () => {}, |
| 152 | activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, | 151 | activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, |
| 153 | markdownCwd: undefined, | 152 | markdownCwd: undefined, |
| ... | @@ -465,7 +464,7 @@ describe("MessagesTimeline", () => { | ... | @@ -465,7 +464,7 @@ describe("MessagesTimeline", () => { |
| 465 | expect(markup).not.toContain('alt="report.pdf"'); | 464 | expect(markup).not.toContain('alt="report.pdf"'); |
| 466 | }); | 465 | }); |
| 467 | 466 | ||
| 468 | it("renders video attachments as play buttons", () => { | 467 | it("renders video attachments with the shared video player", () => { |
| 469 | const entry = { | 468 | const entry = { |
| 470 | ...buildUserTimelineEntry("Watch the demo."), | 469 | ...buildUserTimelineEntry("Watch the demo."), |
| 471 | message: { | 470 | message: { |
| ... | @@ -477,6 +476,7 @@ describe("MessagesTimeline", () => { | ... | @@ -477,6 +476,7 @@ describe("MessagesTimeline", () => { |
| 477 | name: "demo.mp4", | 476 | name: "demo.mp4", |
| 478 | mimeType: "video/mp4", | 477 | mimeType: "video/mp4", |
| 479 | sizeBytes: 42, | 478 | sizeBytes: 42, |
| 479 | previewUrl: "https://environment.test/api/assets/demo.mp4", | ||
| 480 | }, | 480 | }, |
| 481 | ], | 481 | ], |
| 482 | }, | 482 | }, |
| ... | @@ -485,22 +485,37 @@ describe("MessagesTimeline", () => { | ... | @@ -485,22 +485,37 @@ describe("MessagesTimeline", () => { |
| 485 | const markup = renderToStaticMarkup( | 485 | const markup = renderToStaticMarkup( |
| 486 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, | 486 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, |
| 487 | ); | 487 | ); |
| 488 | const busyMarkup = renderToStaticMarkup( | 488 | |
| 489 | <MessagesTimeline | 489 | expect(markup).toContain("<video"); |
| 490 | {...buildProps()} | 490 | expect(markup).toContain('aria-label="demo.mp4"'); |
| 491 | timelineEntries={[entry]} | 491 | expect(markup).toContain('controls=""'); |
| 492 | openingVideoAttachmentId="attachment-demo-mp4" | 492 | expect(markup).not.toContain("Expand demo.mp4"); |
| 493 | />, | 493 | }); |
| 494 | |||
| 495 | it("shows the filename while an optimistic video is unavailable", () => { | ||
| 496 | const entry = { | ||
| 497 | ...buildUserTimelineEntry("Uploading the demo."), | ||
| 498 | message: { | ||
| 499 | ...buildUserTimelineEntry("Uploading the demo.").message, | ||
| 500 | attachments: [ | ||
| 501 | { | ||
| 502 | type: "file" as const, | ||
| 503 | id: "optimistic-demo-mp4", | ||
| 504 | name: "pending-demo.mp4", | ||
| 505 | mimeType: "video/mp4", | ||
| 506 | sizeBytes: 42, | ||
| 507 | downloadable: false, | ||
| 508 | }, | ||
| 509 | ], | ||
| 510 | }, | ||
| 511 | }; | ||
| 512 | |||
| 513 | const markup = renderToStaticMarkup( | ||
| 514 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, | ||
| 494 | ); | 515 | ); |
| 495 | 516 | ||
| 496 | expect(markup).toContain('aria-label="Play demo.mp4"'); | 517 | expect(markup).not.toContain("<video"); |
| 497 | expect(markup).toContain("min-h-[72px]"); | 518 | expect(markup).toContain(">pending-demo.mp4</div>"); |
| 498 | expect(markup).toContain(">demo.mp4</span>"); | ||
| 499 | expect(markup).not.toContain('aria-label="Download demo.mp4"'); | ||
| 500 | expect(busyMarkup).toContain('aria-busy="true"'); | ||
| 501 | expect(busyMarkup).toContain('aria-disabled="true"'); | ||
| 502 | expect(busyMarkup).not.toContain('disabled=""'); | ||
| 503 | expect(busyMarkup).toContain(">Loading…</span>"); | ||
| 504 | }); | 519 | }); |
| 505 | it("renders an ordinary file download button without creating its URL in advance", () => { | 520 | it("renders an ordinary file download button without creating its URL in advance", () => { |
| 506 | const entry = { | 521 | const entry = { |
apps/web/src/components/chat/MessagesTimeline.tsx+53-40| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type AssistantCitation, | 2 | type AssistantCitation, |
| 3 | type ChatFileAttachment, | ||
| 4 | type EnvironmentId, | 3 | type EnvironmentId, |
| 5 | type MessageId, | 4 | type MessageId, |
| 6 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| ... | @@ -50,6 +49,7 @@ import { | ... | @@ -50,6 +49,7 @@ import { |
| 50 | workLogEntryIsToolLike, | 49 | workLogEntryIsToolLike, |
| 51 | } from "../../session-logic"; | 50 | } from "../../session-logic"; |
| 52 | import { | 51 | import { |
| 52 | type ChatFileAttachment, | ||
| 53 | type ChatImageAttachment, | 53 | type ChatImageAttachment, |
| 54 | isBrowserPreviewAttachment, | 54 | isBrowserPreviewAttachment, |
| 55 | isFileAttachment, | 55 | isFileAttachment, |
| ... | @@ -79,7 +79,6 @@ import { | ... | @@ -79,7 +79,6 @@ import { |
| 79 | MessageCircleIcon, | 79 | MessageCircleIcon, |
| 80 | MousePointerClickIcon, | 80 | MousePointerClickIcon, |
| 81 | PaintbrushIcon, | 81 | PaintbrushIcon, |
| 82 | PlayIcon, | ||
| 83 | SearchIcon, | 82 | SearchIcon, |
| 84 | SquarePenIcon, | 83 | SquarePenIcon, |
| 85 | TerminalIcon, | 84 | TerminalIcon, |
| ... | @@ -89,8 +88,14 @@ import { | ... | @@ -89,8 +88,14 @@ import { |
| 89 | ZapIcon, | 88 | ZapIcon, |
| 90 | } from "lucide-react"; | 89 | } from "lucide-react"; |
| 91 | import { Button } from "../ui/button"; | 90 | import { Button } from "../ui/button"; |
| 91 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | ||
| 92 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | ||
| 92 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | 93 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 93 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; | 94 | import { |
| 95 | buildAttachmentVideoAsset, | ||
| 96 | buildExpandedImagePreview, | ||
| 97 | ExpandedImagePreview, | ||
| 98 | } from "./ExpandedImagePreview"; | ||
| 94 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 99 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 95 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 100 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 96 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; | 101 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| ... | @@ -188,7 +193,6 @@ interface TimelineRowSharedState { | ... | @@ -188,7 +193,6 @@ interface TimelineRowSharedState { |
| 188 | onImageExpand: (preview: ExpandedImagePreview) => void; | 193 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 189 | onFileOpen: (attachment: ChatFileAttachment) => void; | 194 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 190 | onFileDownload: (attachment: ChatFileAttachment) => void; | 195 | onFileDownload: (attachment: ChatFileAttachment) => void; |
| 191 | openingVideoAttachmentId: string | null; | ||
| 192 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 196 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 193 | onToggleTurnFold: (turnId: TurnId) => void; | 197 | onToggleTurnFold: (turnId: TurnId) => void; |
| 194 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; | 198 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| ... | @@ -289,7 +293,6 @@ interface MessagesTimelineProps { | ... | @@ -289,7 +293,6 @@ interface MessagesTimelineProps { |
| 289 | onImageExpand: (preview: ExpandedImagePreview) => void; | 293 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 290 | onFileOpen?: (attachment: ChatFileAttachment) => void; | 294 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| 291 | onFileDownload?: (attachment: ChatFileAttachment) => void; | 295 | onFileDownload?: (attachment: ChatFileAttachment) => void; |
| 292 | openingVideoAttachmentId: string | null; | ||
| 293 | activeThreadEnvironmentId: EnvironmentId; | 296 | activeThreadEnvironmentId: EnvironmentId; |
| 294 | markdownCwd: string | undefined; | 297 | markdownCwd: string | undefined; |
| 295 | resolvedTheme: "light" | "dark"; | 298 | resolvedTheme: "light" | "dark"; |
| ... | @@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 341 | onImageExpand, | 344 | onImageExpand, |
| 342 | onFileOpen = NOOP_OPEN_ATTACHMENT, | 345 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| 343 | onFileDownload = NOOP_OPEN_ATTACHMENT, | 346 | onFileDownload = NOOP_OPEN_ATTACHMENT, |
| 344 | openingVideoAttachmentId, | ||
| 345 | activeThreadEnvironmentId, | 347 | activeThreadEnvironmentId, |
| 346 | markdownCwd, | 348 | markdownCwd, |
| 347 | resolvedTheme, | 349 | resolvedTheme, |
| ... | @@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 638 | onImageExpand, | 640 | onImageExpand, |
| 639 | onFileOpen, | 641 | onFileOpen, |
| 640 | onFileDownload, | 642 | onFileDownload, |
| 641 | openingVideoAttachmentId, | ||
| 642 | onOpenTurnDiff, | 643 | onOpenTurnDiff, |
| 643 | onToggleTurnFold, | 644 | onToggleTurnFold, |
| 644 | onToggleWorkGroup, | 645 | onToggleWorkGroup, |
| ... | @@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 663 | onImageExpand, | 664 | onImageExpand, |
| 664 | onFileOpen, | 665 | onFileOpen, |
| 665 | onFileDownload, | 666 | onFileDownload, |
| 666 | openingVideoAttachmentId, | ||
| 667 | onOpenTurnDiff, | 667 | onOpenTurnDiff, |
| 668 | onToggleTurnFold, | 668 | onToggleTurnFold, |
| 669 | onToggleWorkGroup, | 669 | onToggleWorkGroup, |
| ... | @@ -1127,6 +1127,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1127,6 +1127,45 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1127 | ); | 1127 | ); |
| 1128 | }); | 1128 | }); |
| 1129 | 1129 | ||
| 1130 | function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { | ||
| 1131 | const ctx = use(TimelineRowCtx); | ||
| 1132 | const asset = useMemo( | ||
| 1133 | () => | ||
| 1134 | file.downloadable === false | ||
| 1135 | ? null | ||
| 1136 | : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file), | ||
| 1137 | [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name], | ||
| 1138 | ); | ||
| 1139 | const resource = asset?.resource ?? null; | ||
| 1140 | const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); | ||
| 1141 | const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); | ||
| 1142 | const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); | ||
| 1143 | |||
| 1144 | if (asset === null && src === null) { | ||
| 1145 | return ( | ||
| 1146 | <div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg border border-border/80 bg-black px-2 py-3 text-center text-[11px] text-white/70"> | ||
| 1147 | {file.name} | ||
| 1148 | </div> | ||
| 1149 | ); | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | return ( | ||
| 1153 | <MediaVideoPlayer | ||
| 1154 | src={src} | ||
| 1155 | sourceFailed={ | ||
| 1156 | file.previewUrl === undefined && resource !== null && assetUrl._tag === "Failure" | ||
| 1157 | } | ||
| 1158 | label={file.name} | ||
| 1159 | preload="visible" | ||
| 1160 | className="block aspect-[4/3] w-full" | ||
| 1161 | videoClassName="aspect-auto size-full rounded-lg border border-border/80" | ||
| 1162 | stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white" | ||
| 1163 | onRetry={asset ? refreshAssetUrl : undefined} | ||
| 1164 | actionsSource={asset ? { kind: "video", name: file.name, src, asset } : undefined} | ||
| 1165 | /> | ||
| 1166 | ); | ||
| 1167 | } | ||
| 1168 | |||
| 1130 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 1169 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 1131 | const ctx = use(TimelineRowCtx); | 1170 | const ctx = use(TimelineRowCtx); |
| 1132 | // The attachment union has an open member, so guards (not literal type | 1171 | // The attachment union has an open member, so guards (not literal type |
| ... | @@ -1165,12 +1204,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -1165,12 +1204,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1165 | {regularImages.map((image) => ( | 1204 | {regularImages.map((image) => ( |
| 1166 | <div | 1205 | <div |
| 1167 | key={image.id} | 1206 | key={image.id} |
| 1168 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | 1207 | className="aspect-[4/3] overflow-hidden rounded-lg border border-border/80 bg-background/70" |
| 1169 | > | 1208 | > |
| 1170 | {image.previewUrl ? ( | 1209 | {image.previewUrl ? ( |
| 1171 | <button | 1210 | <button |
| 1172 | type="button" | 1211 | type="button" |
| 1173 | className="h-full w-full cursor-zoom-in" | 1212 | className="block h-full w-full cursor-zoom-in" |
| 1174 | aria-label={`Preview ${image.name}`} | 1213 | aria-label={`Preview ${image.name}`} |
| 1175 | onClick={() => { | 1214 | onClick={() => { |
| 1176 | const preview = buildExpandedImagePreview(regularImages, image.id); | 1215 | const preview = buildExpandedImagePreview(regularImages, image.id); |
| ... | @@ -1181,7 +1220,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -1181,7 +1220,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1181 | <img | 1220 | <img |
| 1182 | src={image.previewUrl} | 1221 | src={image.previewUrl} |
| 1183 | alt={image.name} | 1222 | alt={image.name} |
| 1184 | className="block h-auto max-h-[220px] w-full object-cover" | 1223 | className="block size-full object-cover" |
| 1185 | /> | 1224 | /> |
| 1186 | </button> | 1225 | </button> |
| 1187 | ) : ( | 1226 | ) : ( |
| ... | @@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1191 | )} | 1230 | )} |
| 1192 | </div> | 1231 | </div> |
| 1193 | ))} | 1232 | ))} |
| 1194 | {userVideos.map((file) => { | 1233 | {userVideos.map((file) => ( |
| 1195 | const isOpening = ctx.openingVideoAttachmentId === file.id; | 1234 | <UserVideoAttachment key={file.id} file={file} /> |
| 1196 | return ( | 1235 | ))} |
| 1197 | <div | ||
| 1198 | key={file.id} | ||
| 1199 | className="overflow-hidden rounded-lg border border-border/80 bg-black" | ||
| 1200 | > | ||
| 1201 | <button | ||
| 1202 | type="button" | ||
| 1203 | disabled={file.downloadable === false} | ||
| 1204 | className="flex min-h-[72px] w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-2 py-2 text-white disabled:cursor-default disabled:opacity-50 aria-disabled:cursor-default aria-disabled:opacity-50" | ||
| 1205 | aria-busy={isOpening || undefined} | ||
| 1206 | aria-disabled={isOpening || undefined} | ||
| 1207 | aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`} | ||
| 1208 | onClick={() => { | ||
| 1209 | if (isOpening) return; | ||
| 1210 | ctx.onFileOpen(file); | ||
| 1211 | }} | ||
| 1212 | > | ||
| 1213 | {isOpening ? ( | ||
| 1214 | <span className="text-[11px]">Loading…</span> | ||
| 1215 | ) : ( | ||
| 1216 | <PlayIcon className="size-8 fill-current" /> | ||
| 1217 | )} | ||
| 1218 | <span className="max-w-full truncate text-[11px]">{file.name}</span> | ||
| 1219 | </button> | ||
| 1220 | </div> | ||
| 1221 | ); | ||
| 1222 | })} | ||
| 1223 | </div> | 1236 | </div> |
| 1224 | )} | 1237 | )} |
| 1225 | {previewAnnotations.map((annotation, index) => ( | 1238 | {previewAnnotations.map((annotation, index) => ( |
apps/web/src/components/media/MediaActions.tsx+21-16| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | ||
| 1 | import { | 2 | import { |
| 2 | mediaReferenceFileName, | 3 | mediaReferenceFileName, |
| 3 | type MediaReference, | 4 | type MediaReference, |
| ... | @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { | ... | @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { |
| 68 | return { save, copyImage }; | 69 | return { save, copyImage }; |
| 69 | } | 70 | } |
| 70 | 71 | ||
| 71 | type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; | ||
| 72 | |||
| 73 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ | 72 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ |
| 74 | export function MediaActions({ | 73 | export function MediaActions({ |
| 75 | source, | 74 | source, |
| ... | @@ -82,8 +81,6 @@ export function MediaActions({ | ... | @@ -82,8 +81,6 @@ export function MediaActions({ |
| 82 | const [tooltipOpen, setTooltipOpen] = useState(false); | 81 | const [tooltipOpen, setTooltipOpen] = useState(false); |
| 83 | const menuOpen = useRef(false); | 82 | const menuOpen = useRef(false); |
| 84 | const reference = source.reference; | 83 | const reference = source.reference; |
| 85 | const hasActions = | ||
| 86 | source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; | ||
| 87 | const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); | 84 | const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); |
| 88 | 85 | ||
| 89 | const showMenu = async (position: { x: number; y: number }) => { | 86 | const showMenu = async (position: { x: number; y: number }) => { |
| ... | @@ -94,28 +91,37 @@ export function MediaActions({ | ... | @@ -94,28 +91,37 @@ export function MediaActions({ |
| 94 | let failureTitle = "Could not open media menu"; | 91 | let failureTitle = "Could not open media menu"; |
| 95 | let progressToast: ReturnType<typeof toastManager.add> | undefined; | 92 | let progressToast: ReturnType<typeof toastManager.add> | undefined; |
| 96 | try { | 93 | try { |
| 97 | const items: ContextMenuItem<MediaAction>[] = []; | 94 | const noun = source.kind === "image" ? "image" : "video"; |
| 95 | const unavailable = source.src === null && source.asset === undefined; | ||
| 96 | const canCopyImage = | ||
| 97 | typeof navigator !== "undefined" && | ||
| 98 | Boolean(navigator.clipboard?.write) && | ||
| 99 | typeof ClipboardItem !== "undefined"; | ||
| 100 | const items: ContextMenuItem<MediaActionId>[] = []; | ||
| 98 | if (reference?.kind === "file") { | 101 | if (reference?.kind === "file") { |
| 99 | items.push({ id: "copy-full", label: "Copy full path" }); | 102 | items.push({ id: "copy-full-path", label: "Copy full path" }); |
| 100 | if (reference.relativePath) | 103 | if (reference.relativePath) |
| 101 | items.push({ id: "copy-relative", label: "Copy relative path" }); | 104 | items.push({ id: "copy-relative-path", label: "Copy relative path" }); |
| 102 | } else if (reference?.kind === "url") { | 105 | } else if (reference?.kind === "url") { |
| 103 | items.push({ id: "copy-url", label: "Copy URL" }); | 106 | items.push({ id: "copy-url", label: "Copy URL" }); |
| 104 | } | 107 | } |
| 108 | if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); | ||
| 109 | items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); | ||
| 105 | if (source.kind === "image") { | 110 | if (source.kind === "image") { |
| 106 | const unavailable = source.src === null && source.asset === undefined; | 111 | items.push({ |
| 107 | items.push({ id: "save", label: "Save image", disabled: unavailable }); | 112 | id: "copy-image", |
| 108 | items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); | 113 | label: "Copy image", |
| 114 | disabled: unavailable || !canCopyImage, | ||
| 115 | }); | ||
| 109 | } | 116 | } |
| 110 | if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); | ||
| 111 | 117 | ||
| 112 | const action = await api.contextMenu.show(items, position); | 118 | const action = await api.contextMenu.show(items, position); |
| 113 | if (!action) return; | 119 | if (!action) return; |
| 114 | failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; | 120 | failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; |
| 115 | const text = | 121 | const text = |
| 116 | action === "copy-full" && reference?.kind === "file" | 122 | action === "copy-full-path" && reference?.kind === "file" |
| 117 | ? reference.path | 123 | ? reference.path |
| 118 | : action === "copy-relative" && reference?.kind === "file" | 124 | : action === "copy-relative-path" && reference?.kind === "file" |
| 119 | ? reference.relativePath | 125 | ? reference.relativePath |
| 120 | : action === "copy-url" && reference?.kind === "url" | 126 | : action === "copy-url" && reference?.kind === "url" |
| 121 | ? reference.url | 127 | ? reference.url |
| ... | @@ -131,7 +137,7 @@ export function MediaActions({ | ... | @@ -131,7 +137,7 @@ export function MediaActions({ |
| 131 | } else if (action === "save" || action === "copy-image") { | 137 | } else if (action === "save" || action === "copy-image") { |
| 132 | progressToast = toastManager.add({ | 138 | progressToast = toastManager.add({ |
| 133 | type: "loading", | 139 | type: "loading", |
| 134 | title: action === "save" ? "Preparing image download…" : "Copying image…", | 140 | title: action === "save" ? `Preparing ${noun} download…` : "Copying image…", |
| 135 | }); | 141 | }); |
| 136 | await (action === "save" ? save() : copyImage()); | 142 | await (action === "save" ? save() : copyImage()); |
| 137 | toastManager.update(progressToast, { | 143 | toastManager.update(progressToast, { |
| ... | @@ -158,7 +164,7 @@ export function MediaActions({ | ... | @@ -158,7 +164,7 @@ export function MediaActions({ |
| 158 | render={children} | 164 | render={children} |
| 159 | tabIndex={0} | 165 | tabIndex={0} |
| 160 | onContextMenu={(event) => { | 166 | onContextMenu={(event) => { |
| 161 | if (!hasActions || event.defaultPrevented) return; | 167 | if (event.defaultPrevented) return; |
| 162 | event.preventDefault(); | 168 | event.preventDefault(); |
| 163 | event.stopPropagation(); | 169 | event.stopPropagation(); |
| 164 | const bounds = event.currentTarget.getBoundingClientRect(); | 170 | const bounds = event.currentTarget.getBoundingClientRect(); |
| ... | @@ -170,7 +176,6 @@ export function MediaActions({ | ... | @@ -170,7 +176,6 @@ export function MediaActions({ |
| 170 | }} | 176 | }} |
| 171 | onKeyDown={(event) => { | 177 | onKeyDown={(event) => { |
| 172 | if ( | 178 | if ( |
| 173 | !hasActions || | ||
| 174 | event.defaultPrevented || | 179 | event.defaultPrevented || |
| 175 | !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) | 180 | !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) |
| 176 | ) | 181 | ) |
apps/web/src/components/media/MediaVideoPlayer.tsx+12-24| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | 1 | import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; | 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; |
| 3 | 3 | ||
| 4 | import { cn } from "../../lib/utils"; | 4 | import { cn } from "../../lib/utils"; |
| ... | @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { | ... | @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { |
| 14 | readonly originalUrl?: string | undefined; | 14 | readonly originalUrl?: string | undefined; |
| 15 | readonly revision?: string | null | undefined; | 15 | readonly revision?: string | null | undefined; |
| 16 | readonly preload?: "visible" | "metadata" | undefined; | 16 | readonly preload?: "visible" | "metadata" | undefined; |
| 17 | readonly autoPlay?: boolean | undefined; | ||
| 17 | readonly className?: string | undefined; | 18 | readonly className?: string | undefined; |
| 18 | readonly videoClassName?: string | undefined; | 19 | readonly videoClassName?: string | undefined; |
| 20 | /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ | ||
| 21 | readonly stateClassName?: string | undefined; | ||
| 19 | readonly style?: CSSProperties | undefined; | 22 | readonly style?: CSSProperties | undefined; |
| 20 | readonly copyMarkdown?: string | undefined; | 23 | readonly copyMarkdown?: string | undefined; |
| 21 | readonly onExpand?: ((src: string) => void) | undefined; | ||
| 22 | readonly onRetry?: (() => Promise<void>) | undefined; | 24 | readonly onRetry?: (() => Promise<void>) | undefined; |
| 23 | readonly actionsSource?: MediaActionSource | undefined; | 25 | readonly actionsSource?: MediaActionSource | undefined; |
| 24 | } | 26 | } |
| ... | @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ | ... | @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ |
| 31 | originalUrl, | 33 | originalUrl, |
| 32 | revision = null, | 34 | revision = null, |
| 33 | preload = "visible", | 35 | preload = "visible", |
| 36 | autoPlay = false, | ||
| 34 | className, | 37 | className, |
| 35 | videoClassName, | 38 | videoClassName, |
| 39 | stateClassName, | ||
| 36 | style, | 40 | style, |
| 37 | copyMarkdown, | 41 | copyMarkdown, |
| 38 | onExpand, | ||
| 39 | onRetry, | 42 | onRetry, |
| 40 | actionsSource, | 43 | actionsSource, |
| 41 | }: MediaVideoPlayerProps) { | 44 | }: MediaVideoPlayerProps) { |
| ... | @@ -114,23 +117,6 @@ export function MediaVideoPlayer({ | ... | @@ -114,23 +117,6 @@ export function MediaVideoPlayer({ |
| 114 | } | 117 | } |
| 115 | }; | 118 | }; |
| 116 | 119 | ||
| 117 | const expandButton = | ||
| 118 | onExpand && src !== null ? ( | ||
| 119 | <Button | ||
| 120 | type="button" | ||
| 121 | variant="secondary" | ||
| 122 | size="icon-xs" | ||
| 123 | className={failed ? undefined : "absolute right-2 top-2"} | ||
| 124 | aria-label={`Expand ${label || "video"}`} | ||
| 125 | onClick={() => { | ||
| 126 | videoRef.current?.pause(); | ||
| 127 | onExpand(latestSrc ?? src); | ||
| 128 | }} | ||
| 129 | > | ||
| 130 | <Maximize2Icon /> | ||
| 131 | </Button> | ||
| 132 | ) : null; | ||
| 133 | |||
| 134 | const player = ( | 120 | const player = ( |
| 135 | <span | 121 | <span |
| 136 | className={cn("relative inline-block align-middle", className)} | 122 | className={cn("relative inline-block align-middle", className)} |
| ... | @@ -140,7 +126,10 @@ export function MediaVideoPlayer({ | ... | @@ -140,7 +126,10 @@ export function MediaVideoPlayer({ |
| 140 | {failed ? ( | 126 | {failed ? ( |
| 141 | <span | 127 | <span |
| 142 | role="alert" | 128 | role="alert" |
| 143 | className="flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground" | 129 | className={cn( |
| 130 | "flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground", | ||
| 131 | stateClassName, | ||
| 132 | )} | ||
| 144 | > | 133 | > |
| 145 | <span className="inline-flex items-center gap-1.5"> | 134 | <span className="inline-flex items-center gap-1.5"> |
| 146 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> | 135 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> |
| ... | @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ | ... | @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ |
| 159 | </Button> | 148 | </Button> |
| 160 | ) : null} | 149 | ) : null} |
| 161 | <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} /> | 150 | <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} /> |
| 162 | {expandButton} | ||
| 163 | </span> | 151 | </span> |
| 164 | </span> | 152 | </span> |
| 165 | ) : src !== null ? ( | 153 | ) : src !== null ? ( |
| ... | @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ | ... | @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ |
| 168 | ref={videoRef} | 156 | ref={videoRef} |
| 169 | src={src} | 157 | src={src} |
| 170 | aria-label={label || "Video preview"} | 158 | aria-label={label || "Video preview"} |
| 159 | autoPlay={autoPlay} | ||
| 171 | controls | 160 | controls |
| 172 | playsInline | 161 | playsInline |
| 173 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} | 162 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} |
| ... | @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ | ... | @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ |
| 186 | <span | 175 | <span |
| 187 | role="status" | 176 | role="status" |
| 188 | aria-label="Loading video" | 177 | aria-label="Loading video" |
| 189 | className="block aspect-video w-full rounded-lg bg-muted/60" | 178 | className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)} |
| 190 | style={style} | 179 | style={style} |
| 191 | /> | 180 | /> |
| 192 | )} | 181 | )} |
| 193 | {!failed && expandButton} | ||
| 194 | </span> | 182 | </span> |
| 195 | ); | 183 | ); |
| 196 | return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player; | 184 | return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player; |
apps/web/src/filePathDisplay.ts+11-16| ... | @@ -1,22 +1,18 @@ | ... | @@ -1,22 +1,18 @@ |
| 1 | import { splitPathAndPosition } from "./terminal-links"; | 1 | import { |
| 2 | fileBasename, | ||
| 3 | formatFilePathPosition, | ||
| 4 | splitFilePathPosition, | ||
| 5 | stripSlashPrefixedWindowsDrive, | ||
| 6 | } from "@t3tools/client-runtime/markdown-links"; | ||
| 2 | 7 | ||
| 3 | function normalizePathSeparators(path: string): string { | 8 | function normalizePathSeparators(path: string): string { |
| 4 | return path.replaceAll("\\", "/"); | 9 | return path.replaceAll("\\", "/"); |
| 5 | } | 10 | } |
| 6 | 11 | ||
| 7 | function canonicalizeWindowsDrivePath(path: string): string { | ||
| 8 | return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; | ||
| 9 | } | ||
| 10 | |||
| 11 | function trimTrailingPathSeparators(path: string): string { | 12 | function trimTrailingPathSeparators(path: string): string { |
| 12 | return path.replace(/[\\/]+$/, ""); | 13 | return path.replace(/[\\/]+$/, ""); |
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | function basenameOfPath(path: string): string { | ||
| 16 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | ||
| 17 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | ||
| 18 | } | ||
| 19 | |||
| 20 | function stripRelativePrefixes(path: string): string { | 16 | function stripRelativePrefixes(path: string): string { |
| 21 | return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); | 17 | return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); |
| 22 | } | 18 | } |
| ... | @@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath( | ... | @@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath( |
| 25 | pathWithPosition: string, | 21 | pathWithPosition: string, |
| 26 | workspaceRoot: string | undefined, | 22 | workspaceRoot: string | undefined, |
| 27 | ): string { | 23 | ): string { |
| 28 | const { path, line, column } = splitPathAndPosition(pathWithPosition); | 24 | const position = splitFilePathPosition(pathWithPosition); |
| 29 | const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); | 25 | const normalizedPath = stripSlashPrefixedWindowsDrive(normalizePathSeparators(position.path)); |
| 30 | 26 | ||
| 31 | let displayPath = normalizedPath; | 27 | let displayPath = normalizedPath; |
| 32 | if (workspaceRoot) { | 28 | if (workspaceRoot) { |
| 33 | const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( | 29 | const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( |
| 34 | normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), | 30 | normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), |
| 35 | ); | 31 | ); |
| 36 | const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); | 32 | const workspaceLabel = fileBasename(normalizedWorkspaceRoot); |
| 37 | const pathForCompare = normalizedPath.toLowerCase(); | 33 | const pathForCompare = normalizedPath.toLowerCase(); |
| 38 | const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); | 34 | const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); |
| 39 | const workspaceWithSeparator = `${workspaceForCompare}/`; | 35 | const workspaceWithSeparator = `${workspaceForCompare}/`; |
| ... | @@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath( | ... | @@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath( |
| 52 | } | 48 | } |
| 53 | } | 49 | } |
| 54 | 50 | ||
| 55 | if (!line) return displayPath; | 51 | return formatFilePathPosition({ ...position, path: displayPath }); |
| 56 | return `${displayPath}:${line}${column ? `:${column}` : ""}`; | ||
| 57 | } | 52 | } |
apps/web/src/markdown-links.test.ts+8-1| ... | @@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => { | ... | @@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => { |
| 270 | ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); | 270 | ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); |
| 271 | }); | 271 | }); |
| 272 | 272 | ||
| 273 | it("does not treat app routes as file links", () => { | 273 | it("does not treat app routes as file links, even with a line anchor", () => { |
| 274 | expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); | 274 | expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); |
| 275 | expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull(); | ||
| 276 | }); | ||
| 277 | |||
| 278 | it("decodes an encoded drive colon in a file uri before dropping its slash", () => { | ||
| 279 | expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( | ||
| 280 | "c:/Users/x/shot.png", | ||
| 281 | ); | ||
| 275 | }); | 282 | }); |
| 276 | }); | 283 | }); |
| 277 | 284 |
apps/web/src/markdown-links.ts+25-214| ... | @@ -1,53 +1,21 @@ | ... | @@ -1,53 +1,21 @@ |
| 1 | import { | 1 | import { |
| 2 | fileBasename, | ||
| 3 | formatFilePathPosition, | ||
| 2 | inlineCodeFilePathCandidate, | 4 | inlineCodeFilePathCandidate, |
| 3 | isConventionalFilePosition, | 5 | isRelativeFilePath, |
| 6 | normalizeMarkdownLinkDestination, | ||
| 7 | parseFileUrlHref, | ||
| 8 | parseMarkdownFileLink, | ||
| 9 | safeDecodeURIComponent, | ||
| 10 | splitFilePathPosition, | ||
| 11 | workspaceRelativeFilePath, | ||
| 4 | } from "@t3tools/client-runtime/markdown-links"; | 12 | } from "@t3tools/client-runtime/markdown-links"; |
| 5 | 13 | ||
| 6 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; | 14 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 7 | import { | 15 | import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; |
| 8 | isTerminalLinkActivation, | 16 | |
| 9 | resolvePathLinkTarget, | 17 | export { normalizeMarkdownLinkDestination }; |
| 10 | splitPathAndPosition, | ||
| 11 | } from "./terminal-links"; | ||
| 12 | 18 | ||
| 13 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | ||
| 14 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | ||
| 15 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | ||
| 16 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | ||
| 17 | const RELATIVE_FILE_PATH_PATTERN = | ||
| 18 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; | ||
| 19 | const RELATIVE_FILE_NAME_PATTERN = | ||
| 20 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | ||
| 21 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | ||
| 22 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | ||
| 23 | // Standard OS and dev-container roots; deliberately excludes app-route-ish | ||
| 24 | // prefixes like /app/ or /chat/ so SPA routes never read as files. | ||
| 25 | const POSIX_FILE_ROOT_PREFIXES = [ | ||
| 26 | "/Users/", | ||
| 27 | "/home/", | ||
| 28 | "/tmp/", | ||
| 29 | "/var/", | ||
| 30 | "/etc/", | ||
| 31 | "/opt/", | ||
| 32 | "/mnt/", | ||
| 33 | "/Volumes/", | ||
| 34 | "/private/", | ||
| 35 | "/root/", | ||
| 36 | "/usr/", | ||
| 37 | "/bin/", | ||
| 38 | "/sbin/", | ||
| 39 | "/lib/", | ||
| 40 | "/lib64/", | ||
| 41 | "/srv/", | ||
| 42 | "/dev/", | ||
| 43 | "/proc/", | ||
| 44 | "/sys/", | ||
| 45 | "/run/", | ||
| 46 | "/boot/", | ||
| 47 | "/media/", | ||
| 48 | "/workspace/", | ||
| 49 | "/workspaces/", | ||
| 50 | ] as const; | ||
| 51 | const MARKDOWN_LINK_HREF_PATTERN = | 19 | const MARKDOWN_LINK_HREF_PATTERN = |
| 52 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; | 20 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; |
| 53 | 21 | ||
| ... | @@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool | ... | @@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool |
| 81 | return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | 49 | return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 82 | } | 50 | } |
| 83 | 51 | ||
| 84 | function safeDecode(value: string): string { | ||
| 85 | try { | ||
| 86 | return decodeURIComponent(value); | ||
| 87 | } catch { | ||
| 88 | return value; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | export function isWindowsDrivePathHref(href: string): boolean { | 52 | export function isWindowsDrivePathHref(href: string): boolean { |
| 93 | return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); | 53 | return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); |
| 94 | } | ||
| 95 | |||
| 96 | function unwrapMarkdownLinkDestination(value: string): string { | ||
| 97 | return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; | ||
| 98 | } | ||
| 99 | |||
| 100 | export function normalizeMarkdownLinkDestination(value: string): string { | ||
| 101 | return unwrapMarkdownLinkDestination(value.trim()); | ||
| 102 | } | ||
| 103 | |||
| 104 | function stripSearchAndHash(value: string): { path: string; hash: string } { | ||
| 105 | const hashIndex = value.indexOf("#"); | ||
| 106 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; | ||
| 107 | const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : ""; | ||
| 108 | const queryIndex = pathWithSearch.indexOf("?"); | ||
| 109 | const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch; | ||
| 110 | return { path, hash: rawHash }; | ||
| 111 | } | ||
| 112 | |||
| 113 | function normalizeWindowsDrivePath(path: string): string { | ||
| 114 | return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; | ||
| 115 | } | ||
| 116 | |||
| 117 | function parseFileUrlHref( | ||
| 118 | href: string, | ||
| 119 | options?: { readonly decodePath?: boolean }, | ||
| 120 | ): { path: string; hash: string } | null { | ||
| 121 | try { | ||
| 122 | const parsed = new URL(href); | ||
| 123 | if (parsed.protocol.toLowerCase() !== "file:") return null; | ||
| 124 | |||
| 125 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; | ||
| 126 | const rawPath = uncHostname | ||
| 127 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` | ||
| 128 | : parsed.pathname; | ||
| 129 | if (rawPath.length === 0) return null; | ||
| 130 | |||
| 131 | // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. | ||
| 132 | const normalizedPath = normalizeWindowsDrivePath(rawPath); | ||
| 133 | |||
| 134 | return { | ||
| 135 | path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), | ||
| 136 | hash: parsed.hash, | ||
| 137 | }; | ||
| 138 | } catch { | ||
| 139 | return null; | ||
| 140 | } | ||
| 141 | } | 54 | } |
| 142 | 55 | ||
| 143 | export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { | 56 | export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { |
| 144 | if (!href) return null; | 57 | if (!href) return null; |
| 145 | const normalizedHref = normalizeMarkdownLinkDestination(href); | 58 | const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); |
| 146 | const target = parseFileUrlHref(normalizedHref, { decodePath: false }); | 59 | return target ? `${target.path}${target.hash}` : null; |
| 147 | if (!target) return null; | ||
| 148 | return `${target.path}${target.hash}`; | ||
| 149 | } | ||
| 150 | |||
| 151 | function looksLikePosixFilesystemPath(path: string): boolean { | ||
| 152 | if (!path.startsWith("/")) return false; | ||
| 153 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; | ||
| 154 | if (POSITION_SUFFIX_PATTERN.test(path)) return true; | ||
| 155 | const basename = path.slice(path.lastIndexOf("/") + 1); | ||
| 156 | return /\.[A-Za-z0-9_-]+$/.test(basename); | ||
| 157 | } | ||
| 158 | |||
| 159 | function appendLineColumnFromHash(path: string, hash: string): string { | ||
| 160 | if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path; | ||
| 161 | const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i); | ||
| 162 | if (!match?.[1]) return path; | ||
| 163 | const line = match[1]; | ||
| 164 | const column = match[2]; | ||
| 165 | return `${path}:${line}${column ? `:${column}` : ""}`; | ||
| 166 | } | ||
| 167 | |||
| 168 | function isLikelyPathCandidate(path: string): boolean { | ||
| 169 | if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; | ||
| 170 | if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; | ||
| 171 | if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); | ||
| 172 | return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); | ||
| 173 | } | ||
| 174 | |||
| 175 | function isRelativePath(path: string): boolean { | ||
| 176 | return ( | ||
| 177 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | ||
| 178 | (!path.startsWith("/") && | ||
| 179 | !WINDOWS_DRIVE_PATH_PATTERN.test(path) && | ||
| 180 | !WINDOWS_UNC_PATH_PATTERN.test(path)) | ||
| 181 | ); | ||
| 182 | } | ||
| 183 | |||
| 184 | function hasExternalScheme(path: string): boolean { | ||
| 185 | const match = path.match(EXTERNAL_SCHEME_PATTERN); | ||
| 186 | if (!match) return false; | ||
| 187 | const rest = match[2] ?? ""; | ||
| 188 | if (rest.startsWith("//")) return true; | ||
| 189 | return !POSITION_ONLY_PATTERN.test(rest); | ||
| 190 | } | 60 | } |
| 191 | 61 | ||
| 192 | /** | 62 | /** |
| ... | @@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget( | ... | @@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget( |
| 200 | baseDir: string | undefined = cwd, | 70 | baseDir: string | undefined = cwd, |
| 201 | ): string | null { | 71 | ): string | null { |
| 202 | if (!href) return null; | 72 | if (!href) return null; |
| 203 | const rawHref = normalizeMarkdownLinkDestination(href); | 73 | const target = parseMarkdownFileLink(href); |
| 204 | if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null; | 74 | if (!target) return null; |
| 205 | |||
| 206 | const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") | ||
| 207 | ? parseFileUrlHref(rawHref) | ||
| 208 | : null; | ||
| 209 | const source = fileUrlTarget ?? stripSearchAndHash(rawHref); | ||
| 210 | const decodedPath = normalizeWindowsDrivePath( | ||
| 211 | fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()), | ||
| 212 | ); | ||
| 213 | const decodedHash = safeDecode(source.hash.trim()); | ||
| 214 | |||
| 215 | if (decodedPath.length === 0) return null; | ||
| 216 | if ( | ||
| 217 | !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) && | ||
| 218 | !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) && | ||
| 219 | hasExternalScheme(decodedPath) | ||
| 220 | ) { | ||
| 221 | return null; | ||
| 222 | } | ||
| 223 | |||
| 224 | if (!isLikelyPathCandidate(decodedPath)) return null; | ||
| 225 | |||
| 226 | const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash); | ||
| 227 | if (!isRelativePath(pathWithPosition)) { | ||
| 228 | return pathWithPosition; | ||
| 229 | } | ||
| 230 | 75 | ||
| 76 | const pathWithPosition = formatFilePathPosition(target); | ||
| 77 | if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; | ||
| 231 | if (!baseDir) return null; | 78 | if (!baseDir) return null; |
| 232 | return resolvePathLinkTarget(pathWithPosition, baseDir); | 79 | return resolvePathLinkTarget(pathWithPosition, baseDir); |
| 233 | } | 80 | } |
| ... | @@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta( | ... | @@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta( |
| 246 | const candidate = inlineCodeFilePathCandidate(codeText); | 93 | const candidate = inlineCodeFilePathCandidate(codeText); |
| 247 | if (candidate === null) return null; | 94 | if (candidate === null) return null; |
| 248 | 95 | ||
| 249 | const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); | 96 | return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); |
| 250 | if (resolved) return resolved; | ||
| 251 | |||
| 252 | // `Makefile:12` — conventional extensionless names fail the generic | ||
| 253 | // markdown-link candidate patterns, but here the :line suffix already | ||
| 254 | // marked the span as a file reference. | ||
| 255 | if (baseDir && isConventionalFilePosition(candidate)) { | ||
| 256 | return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); | ||
| 257 | } | ||
| 258 | return null; | ||
| 259 | } | ||
| 260 | |||
| 261 | function basenameOfPath(path: string): string { | ||
| 262 | // A trailing separator is a valid way to write a directory, so trim it before | ||
| 263 | // taking the final segment. Without this the segment reads as empty and the | ||
| 264 | // chip renders with no label at all. | ||
| 265 | const trimmed = path.replace(/[/\\]+$/, "") || path; | ||
| 266 | const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); | ||
| 267 | return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; | ||
| 268 | } | ||
| 269 | |||
| 270 | function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { | ||
| 271 | if (!workspaceRoot) return null; | ||
| 272 | const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); | ||
| 273 | const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( | ||
| 274 | /\/+$/, | ||
| 275 | "", | ||
| 276 | ); | ||
| 277 | const pathForCompare = normalizedPath.toLowerCase(); | ||
| 278 | const rootForCompare = normalizedRoot.toLowerCase(); | ||
| 279 | if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; | ||
| 280 | return normalizedPath.slice(normalizedRoot.length + 1); | ||
| 281 | } | 97 | } |
| 282 | 98 | ||
| 283 | export function resolveMarkdownFileLinkMeta( | 99 | export function resolveMarkdownFileLinkMeta( |
| ... | @@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta( | ... | @@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta( |
| 291 | } | 107 | } |
| 292 | 108 | ||
| 293 | function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { | 109 | function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { |
| 294 | const { path, line, column } = splitPathAndPosition(targetPath); | 110 | const { path, line, column } = splitFilePathPosition(targetPath); |
| 295 | const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; | ||
| 296 | const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; | ||
| 297 | const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; | ||
| 298 | const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; | ||
| 299 | |||
| 300 | return { | 111 | return { |
| 301 | filePath: path, | 112 | filePath: path, |
| 302 | targetPath, | 113 | targetPath, |
| 303 | displayPath: formatWorkspaceRelativePath(targetPath, cwd), | 114 | displayPath: formatWorkspaceRelativePath(targetPath, cwd), |
| 304 | workspaceRelativePath: workspaceRelativePath(path, cwd), | 115 | workspaceRelativePath: workspaceRelativeFilePath(path, cwd), |
| 305 | basename: basenameOfPath(path), | 116 | basename: fileBasename(path), |
| 306 | ...(lineNumber !== undefined ? { line: lineNumber } : {}), | 117 | ...(line !== undefined ? { line } : {}), |
| 307 | ...(columnNumber !== undefined ? { column: columnNumber } : {}), | 118 | ...(column !== undefined ? { column } : {}), |
| 308 | }; | 119 | }; |
| 309 | } | 120 | } |
apps/web/src/terminal-links.ts+8-32| ... | @@ -1,3 +1,8 @@ | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | ||
| 2 | formatFilePathPosition, | ||
| 3 | splitFilePathPosition, | ||
| 4 | } from "@t3tools/client-runtime/markdown-links"; | ||
| 5 | |||
| 1 | import { isMacPlatform } from "./lib/utils"; | 6 | import { isMacPlatform } from "./lib/utils"; |
| 2 | 7 | ||
| 3 | export type TerminalLinkKind = "url" | "path"; | 8 | export type TerminalLinkKind = "url" | "path"; |
| ... | @@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined { | ... | @@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined { |
| 137 | return undefined; | 142 | return undefined; |
| 138 | } | 143 | } |
| 139 | 144 | ||
| 140 | export function splitPathAndPosition(value: string): { | ||
| 141 | path: string; | ||
| 142 | line: string | undefined; | ||
| 143 | column: string | undefined; | ||
| 144 | } { | ||
| 145 | let path = value; | ||
| 146 | let column: string | undefined; | ||
| 147 | let line: string | undefined; | ||
| 148 | |||
| 149 | const columnMatch = path.match(/:(\d+)$/); | ||
| 150 | if (!columnMatch?.[1]) { | ||
| 151 | return { path, line: undefined, column: undefined }; | ||
| 152 | } | ||
| 153 | |||
| 154 | column = columnMatch[1]; | ||
| 155 | path = path.slice(0, -columnMatch[0].length); | ||
| 156 | |||
| 157 | const lineMatch = path.match(/:(\d+)$/); | ||
| 158 | if (lineMatch?.[1]) { | ||
| 159 | line = lineMatch[1]; | ||
| 160 | path = path.slice(0, -lineMatch[0].length); | ||
| 161 | } else { | ||
| 162 | line = column; | ||
| 163 | column = undefined; | ||
| 164 | } | ||
| 165 | |||
| 166 | return { path, line, column }; | ||
| 167 | } | ||
| 168 | |||
| 169 | export function extractTerminalLinks(line: string): TerminalLinkMatch[] { | 145 | export function extractTerminalLinks(line: string): TerminalLinkMatch[] { |
| 170 | const urlMatches = collectMatches(line, "url", URL_PATTERN, []); | 146 | const urlMatches = collectMatches(line, "url", URL_PATTERN, []); |
| 171 | const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); | 147 | const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); |
| ... | @@ -271,7 +247,8 @@ export function isTerminalLinkActivation( | ... | @@ -271,7 +247,8 @@ export function isTerminalLinkActivation( |
| 271 | } | 247 | } |
| 272 | 248 | ||
| 273 | export function resolvePathLinkTarget(rawPath: string, cwd: string): string { | 249 | export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 274 | const { path, line, column } = splitPathAndPosition(rawPath); | 250 | const position = splitFilePathPosition(rawPath); |
| 251 | const { path } = position; | ||
| 275 | 252 | ||
| 276 | let resolvedPath = path; | 253 | let resolvedPath = path; |
| 277 | if (path.startsWith("~/")) { | 254 | if (path.startsWith("~/")) { |
| ... | @@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { | ... | @@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 285 | resolvedPath = joinPath(cwd, path, separator); | 262 | resolvedPath = joinPath(cwd, path, separator); |
| 286 | } | 263 | } |
| 287 | 264 | ||
| 288 | if (!line) return resolvedPath; | 265 | return formatFilePathPosition({ ...position, path: resolvedPath }); |
| 289 | return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; | ||
| 290 | } | 266 | } |
docs/internals/mobile-navigation.md+13-6| ... | @@ -86,10 +86,15 @@ composer thumbnails, and workspace image previews. The workspace PDF web preview | ... | @@ -86,10 +86,15 @@ composer thumbnails, and workspace image previews. The workspace PDF web preview |
| 86 | Open PDF action for the native viewer. Android retains its image viewer and uses the | 86 | Open PDF action for the native viewer. Android retains its image viewer and uses the |
| 87 | system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. | 87 | system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. |
| 88 | 88 | ||
| 89 | Received videos open directly from their signed asset URL. AVKit handles buffering; | 89 | Every full-screen video preview on iOS is AVKit, playing directly from the signed asset |
| 90 | the client does not download the entire file or show a separate opening overlay before | 90 | URL: sent attachments, composer drafts, and file links that point at a video. Inline |
| 91 | presentation. The URL is captured once per preview so credential refresh does not | 91 | markdown embeds and the workspace file screen play in place with the Expo Video player. |
| 92 | restart playback. Saving or sharing still downloads the original file. | 92 | AVKit handles buffering; the client does not download the entire file or show a separate |
| 93 | opening overlay before presentation. | ||
| 94 | The URL is captured once per preview so credential refresh does not restart playback. | ||
| 95 | Media actions (copy path, open in file viewer, save or share) belong to the surface that | ||
| 96 | opened the video, a long-press on the thumbnail, so the player carries no | ||
| 97 | 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. | ||
| 93 | 98 | ||
| 94 | The native presentation promise completes after dismissal. Local draft previews | 99 | The native presentation promise completes after dismissal. Local draft previews |
| 95 | hold their file lease until that promise settles. The iOS preview component requests | 100 | 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 | ... | @@ -97,8 +102,10 @@ native dismissal when its source screen unmounts. Playback pauses in the backgro |
| 97 | AVPlayer activates audio as playback starts. The presenter pauses and releases | 102 | AVPlayer activates audio as playback starts. The presenter pauses and releases |
| 98 | its own player on close, then restores the previous audio-session configuration | 103 | its own player on close, then restores the previous audio-session configuration |
| 99 | if no other component changed it during playback. It does not deactivate the | 104 | if no other component changed it during playback. It does not deactivate the |
| 100 | shared session, which may still serve another player or recorder. Android retains | 105 | shared session, which may still serve another player or recorder. Android plays every |
| 101 | its React Native modal and Expo Video player. | 106 | video, attachments included, in its React Native modal with the Expo Video player. |
| 107 | Composer drafts play and share from their local file; other videos stream from the | ||
| 108 | signed URL and download only for save or share. | ||
| 102 | 109 | ||
| 103 | `shareFileFromSource` uses the same source registration to anchor UIKit's activity | 110 | `shareFileFromSource` uses the same source registration to anchor UIKit's activity |
| 104 | controller. Its promise completes when the native share flow finishes, keeping | 111 | controller. Its promise completes when the native share flow finishes, keeping |
docs/user/composer.md+11-11| ... | @@ -37,12 +37,12 @@ download button beside it to save a copy. Other attached files download when sel | ... | @@ -37,12 +37,12 @@ download button beside it to save a copy. Other attached files download when sel |
| 37 | Select a video attachment before or after sending to play it. Web and desktop use the browser's | 37 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 38 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. | 38 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. |
| 39 | Supported videos show a thumbnail in the conversation and composer. | 39 | Supported videos show a thumbnail in the conversation and composer. |
| 40 | On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs | 40 | Received videos stream from their environment as they play on every platform. Supported formats and |
| 41 | depend on the browser or device; you can save an unsupported video to open it in another app. | 41 | codecs depend on the browser or device; you can save an unsupported video to open it in another app. |
| 42 | 42 | ||
| 43 | On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the | 43 | On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the |
| 44 | conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open | 44 | conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open |
| 45 | the system share options. On Android, use **Save or share video** inside the preview. | 45 | the system share options. On Android, the same menu is also available inside the preview. |
| 46 | 46 | ||
| 47 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name | 47 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name |
| 48 | and shows **Attach again** next to it. Attach the file again or remove it, then send. | 48 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
| ... | @@ -97,20 +97,20 @@ open a media preview. | ... | @@ -97,20 +97,20 @@ open a media preview. |
| 97 | Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They | 97 | Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They |
| 98 | stream from the environment as needed, rather than downloading the entire video before playback. | 98 | stream from the environment as needed, rather than downloading the entire video before playback. |
| 99 | Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; | 99 | Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; |
| 100 | video embeds show a player with controls and an option to expand. Visible video previews load | 100 | video embeds show a player with the browser's controls, full screen included. Visible video previews load |
| 101 | an initial frame when supported, but stay paused until you press Play. Video file references use | 101 | an initial frame when supported, but stay paused until you press Play. Video file references use |
| 102 | a filmstrip icon. | 102 | a filmstrip icon. |
| 103 | 103 | ||
| 104 | On web and desktop, hover over a preview to see its full file path or original URL. Right-click | 104 | On web and desktop, hover over a preview to see its full file path or original URL. Right-click |
| 105 | to copy that reference, save an image, or copy an image to the clipboard. Use the video player's | 105 | to copy that reference, save the image or video, or copy an image to the clipboard. The video |
| 106 | built-in controls to download videos. If the player cannot decode a video, its error message | 106 | player's built-in controls can download a video too. If the player cannot decode a video, its error message |
| 107 | offers a link to open the source in the browser. Workspace media also offers **Copy relative | 107 | offers a link to open the source in the browser. Workspace media also offers **Copy relative |
| 108 | path** and **Open in file viewer**. These actions are available in expanded previews too. | 108 | path** and **Open in file viewer**. These actions are available in expanded previews too. |
| 109 | 109 | ||
| 110 | On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its | 110 | On mobile, touch and hold an inline image or a video thumbnail to see its source, |
| 111 | source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file | 111 | copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer |
| 112 | viewer from the same menu. Saving downloads a copy only when you request it; it does not change | 112 | from the same menu. Saving downloads a copy only when you request it; it does not change how |
| 113 | how the video buffers during playback. On iOS, touch and hold a file reference in a message to | 113 | the video buffers during playback. On iOS, touch and hold a file reference in a message to |
| 114 | copy its full or relative path or open it in the file viewer. | 114 | copy its full or relative path or open it in the file viewer. |
| 115 | 115 | ||
| 116 | Use Markdown image syntax to embed either kind of media: | 116 | Use Markdown image syntax to embed either kind of media: |
packages/client-runtime/package.json+8| ... | @@ -27,6 +27,14 @@ | ... | @@ -27,6 +27,14 @@ |
| 27 | "types": "./src/mediaReference.ts", | 27 | "types": "./src/mediaReference.ts", |
| 28 | "default": "./src/mediaReference.ts" | 28 | "default": "./src/mediaReference.ts" |
| 29 | }, | 29 | }, |
| 30 | "./media-source": { | ||
| 31 | "types": "./src/mediaSource.ts", | ||
| 32 | "default": "./src/mediaSource.ts" | ||
| 33 | }, | ||
| 34 | "./media-actions": { | ||
| 35 | "types": "./src/mediaActions.ts", | ||
| 36 | "default": "./src/mediaActions.ts" | ||
| 37 | }, | ||
| 30 | "./codex-file-citations": { | 38 | "./codex-file-citations": { |
| 31 | "types": "./src/codexFileCitations.ts", | 39 | "types": "./src/codexFileCitations.ts", |
| 32 | "default": "./src/codexFileCitations.ts" | 40 | "default": "./src/codexFileCitations.ts" |
packages/client-runtime/src/markdownImages.ts+24-56| ... | @@ -1,66 +1,27 @@ | ... | @@ -1,66 +1,27 @@ |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | normalizeMarkdownLinkDestination, | ||
| 5 | parseFileUrlHref, | ||
| 6 | safeDecodeURIComponent, | ||
| 7 | splitMarkdownLinkSearchAndHash, | ||
| 8 | stripSlashPrefixedWindowsDrive, | ||
| 9 | } from "./markdownLinks.ts"; | ||
| 10 | |||
| 1 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; | 11 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; |
| 2 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; | 12 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; |
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | ||
| 4 | 13 | ||
| 5 | export type MarkdownImageSource = | 14 | export type MarkdownImageSource = |
| 6 | | { readonly _tag: "Direct"; readonly uri: string } | 15 | | { readonly _tag: "Direct"; readonly uri: string } |
| 7 | | { readonly _tag: "WorkspaceFile"; readonly path: string } | 16 | | { readonly _tag: "WorkspaceFile"; readonly path: string } |
| 8 | | { readonly _tag: "Blocked" }; | 17 | | { readonly _tag: "Blocked" }; |
| 9 | 18 | ||
| 10 | function safeDecode(value: string): string { | ||
| 11 | try { | ||
| 12 | return decodeURIComponent(value); | ||
| 13 | } catch { | ||
| 14 | return value; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | function normalizeSource(value: string): string { | ||
| 19 | const trimmed = value.trim(); | ||
| 20 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | ||
| 21 | } | ||
| 22 | |||
| 23 | export function markdownImageSourceFragment(source: string): string { | 19 | export function markdownImageSourceFragment(source: string): string { |
| 24 | const normalizedSource = normalizeSource(source); | 20 | return splitMarkdownLinkSearchAndHash(normalizeMarkdownLinkDestination(source)).hash; |
| 25 | const hashIndex = normalizedSource.indexOf("#"); | ||
| 26 | return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; | ||
| 27 | } | ||
| 28 | |||
| 29 | function normalizeWindowsDrivePath(value: string): string { | ||
| 30 | return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; | ||
| 31 | } | ||
| 32 | |||
| 33 | function parseFileUrl(value: string): string | null { | ||
| 34 | try { | ||
| 35 | const parsed = new URL(value); | ||
| 36 | if (parsed.protocol.toLowerCase() !== "file:") return null; | ||
| 37 | |||
| 38 | if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") { | ||
| 39 | const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\"); | ||
| 40 | return `\\\\${safeDecode(parsed.hostname)}${pathname}`; | ||
| 41 | } | ||
| 42 | |||
| 43 | const pathname = safeDecode(parsed.pathname); | ||
| 44 | if (pathname.length === 0) return null; | ||
| 45 | return normalizeWindowsDrivePath(pathname); | ||
| 46 | } catch { | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | function stripSearchAndHash(value: string): string { | ||
| 52 | const searchIndex = value.indexOf("?"); | ||
| 53 | const hashIndex = value.indexOf("#"); | ||
| 54 | const end = [searchIndex, hashIndex] | ||
| 55 | .filter((index) => index >= 0) | ||
| 56 | .reduce((lowest, index) => Math.min(lowest, index), value.length); | ||
| 57 | return value.slice(0, end); | ||
| 58 | } | 21 | } |
| 59 | 22 | ||
| 60 | function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { | 23 | function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { |
| 61 | const windows = | 24 | const separator = isWindowsAbsolutePath(workspaceRoot) ? "\\" : "/"; |
| 62 | WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); | ||
| 63 | const separator = windows ? "\\" : "/"; | ||
| 64 | const root = workspaceRoot.replace(/[\\/]+$/, ""); | 25 | const root = workspaceRoot.replace(/[\\/]+$/, ""); |
| 65 | const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); | 26 | const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); |
| 66 | return `${root}${separator}${path}`; | 27 | return `${root}${separator}${path}`; |
| ... | @@ -77,7 +38,7 @@ export function classifyMarkdownImageSource( | ... | @@ -77,7 +38,7 @@ export function classifyMarkdownImageSource( |
| 77 | ): MarkdownImageSource { | 38 | ): MarkdownImageSource { |
| 78 | if (value === null || value === undefined) return { _tag: "Blocked" }; | 39 | if (value === null || value === undefined) return { _tag: "Blocked" }; |
| 79 | 40 | ||
| 80 | const source = normalizeSource(value); | 41 | const source = normalizeMarkdownLinkDestination(value); |
| 81 | if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { | 42 | if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { |
| 82 | return { _tag: "Blocked" }; | 43 | return { _tag: "Blocked" }; |
| 83 | } | 44 | } |
| ... | @@ -86,13 +47,20 @@ export function classifyMarkdownImageSource( | ... | @@ -86,13 +47,20 @@ export function classifyMarkdownImageSource( |
| 86 | } | 47 | } |
| 87 | 48 | ||
| 88 | if (/^file:/i.test(source)) { | 49 | if (/^file:/i.test(source)) { |
| 89 | const path = parseFileUrl(source); | 50 | const target = parseFileUrlHref(source); |
| 90 | return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; | 51 | return target === null |
| 52 | ? { _tag: "Blocked" } | ||
| 53 | : { | ||
| 54 | _tag: "WorkspaceFile", | ||
| 55 | path: stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)), | ||
| 56 | }; | ||
| 91 | } | 57 | } |
| 92 | 58 | ||
| 93 | const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source))); | 59 | const path = stripSlashPrefixedWindowsDrive( |
| 60 | safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), | ||
| 61 | ); | ||
| 94 | if (path.length === 0) return { _tag: "Blocked" }; | 62 | if (path.length === 0) return { _tag: "Blocked" }; |
| 95 | if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) { | 63 | if (path.startsWith("/") || isWindowsAbsolutePath(path)) { |
| 96 | return { _tag: "WorkspaceFile", path }; | 64 | return { _tag: "WorkspaceFile", path }; |
| 97 | } | 65 | } |
| 98 | if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { | 66 | if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { |
packages/client-runtime/src/markdownLinks.test.ts+141-1| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js"; | 3 | import { |
| 4 | fileBasename, | ||
| 5 | inlineCodeFilePathCandidate, | ||
| 6 | isConventionalFilePosition, | ||
| 7 | parseFileUrlHref, | ||
| 8 | parseMarkdownFileLink, | ||
| 9 | splitFilePathPosition, | ||
| 10 | workspaceRelativeFilePath, | ||
| 11 | } from "./markdownLinks.ts"; | ||
| 4 | 12 | ||
| 5 | describe("inlineCodeFilePathCandidate", () => { | 13 | describe("inlineCodeFilePathCandidate", () => { |
| 6 | it.each([ | 14 | it.each([ |
| ... | @@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => { | ... | @@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => { |
| 28 | expect(isConventionalFilePosition("port:3000")).toBe(false); | 36 | expect(isConventionalFilePosition("port:3000")).toBe(false); |
| 29 | }); | 37 | }); |
| 30 | }); | 38 | }); |
| 39 | |||
| 40 | describe("parseFileUrlHref", () => { | ||
| 41 | it.each([ | ||
| 42 | ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], | ||
| 43 | [ | ||
| 44 | "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", | ||
| 45 | "D:/Programme/t3code/OpenInPicker.tsx", | ||
| 46 | "#L69", | ||
| 47 | ], | ||
| 48 | ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], | ||
| 49 | ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], | ||
| 50 | ])("parses %s", (href, path, hash) => { | ||
| 51 | expect(parseFileUrlHref(href)).toEqual({ path, hash }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("keeps percent-encoding so the caller decodes once", () => { | ||
| 55 | expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( | ||
| 56 | "/Users/julius/project/file%2520name.md", | ||
| 57 | ); | ||
| 58 | expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { | ||
| 62 | expect(parseFileUrlHref(href)).toBeNull(); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("splitFilePathPosition", () => { | ||
| 67 | it.each([ | ||
| 68 | ["src/main.ts", "", { path: "src/main.ts" }], | ||
| 69 | ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], | ||
| 70 | ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], | ||
| 71 | ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], | ||
| 72 | ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], | ||
| 73 | ["src/main.ts:0", "", { path: "src/main.ts" }], | ||
| 74 | ["src/main.ts", "#section", { path: "src/main.ts" }], | ||
| 75 | ])("splits %s%s", (path, hash, expected) => { | ||
| 76 | expect(splitFilePathPosition(path, hash)).toEqual(expected); | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | describe("parseMarkdownFileLink", () => { | ||
| 81 | // Both clients consume this table, so a path the web app recognizes is one | ||
| 82 | // the mobile app recognizes too. | ||
| 83 | it.each([ | ||
| 84 | ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], | ||
| 85 | ["/home/me/notes.md", "/home/me/notes.md"], | ||
| 86 | ["/usr/local/bin/tool", "/usr/local/bin/tool"], | ||
| 87 | ["/workspace/Makefile", "/workspace/Makefile"], | ||
| 88 | ["/tmp/favicons/", "/tmp/favicons/"], | ||
| 89 | ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], | ||
| 90 | ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], | ||
| 91 | ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], | ||
| 92 | ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], | ||
| 93 | ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"], | ||
| 94 | ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], | ||
| 95 | ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], | ||
| 96 | ["file://localhost/home/me/notes.md", "/home/me/notes.md"], | ||
| 97 | ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], | ||
| 98 | ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], | ||
| 99 | ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], | ||
| 100 | ["./scripts/deploy", "./scripts/deploy"], | ||
| 101 | ["~/notes/today.md", "~/notes/today.md"], | ||
| 102 | ["AGENTS.md", "AGENTS.md"], | ||
| 103 | ["script.ts:10", "script.ts"], | ||
| 104 | ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], | ||
| 105 | ])("recognizes %s as a file", (href, path) => { | ||
| 106 | expect(parseMarkdownFileLink(href)?.path).toBe(path); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it.each([ | ||
| 110 | "", | ||
| 111 | "#anchor", | ||
| 112 | "//cdn.example.com/clip.mp4", | ||
| 113 | "https://example.com/docs", | ||
| 114 | "mailto:someone@example.com", | ||
| 115 | "javascript:alert(1)", | ||
| 116 | "/chat/settings", | ||
| 117 | "/chat/settings#L3", | ||
| 118 | "/app#L1", | ||
| 119 | "readme", | ||
| 120 | "TODO:12", | ||
| 121 | ])("does not treat %s as a file", (href) => { | ||
| 122 | expect(parseMarkdownFileLink(href)).toBeNull(); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("accepts conventional extensionless names with or without a position", () => { | ||
| 126 | expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); | ||
| 127 | expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); | ||
| 128 | expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("reads positions from suffixes and line anchors", () => { | ||
| 132 | expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ | ||
| 133 | path: "/Users/julius/project/src/main.ts", | ||
| 134 | line: 42, | ||
| 135 | column: 7, | ||
| 136 | }); | ||
| 137 | expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ | ||
| 138 | path: "\\\\server\\share\\src\\main.ts", | ||
| 139 | line: 42, | ||
| 140 | column: 7, | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | }); | ||
| 144 | |||
| 145 | describe("fileBasename", () => { | ||
| 146 | it.each([ | ||
| 147 | ["/tmp/favicons/", "favicons"], | ||
| 148 | ["C:\\Users\\kelchm\\.claude\\", ".claude"], | ||
| 149 | ["/tmp/", "tmp"], | ||
| 150 | ["AGENTS.md", "AGENTS.md"], | ||
| 151 | ["/", "/"], | ||
| 152 | ])("labels %s as %s", (path, basename) => { | ||
| 153 | expect(fileBasename(path)).toBe(basename); | ||
| 154 | }); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("workspaceRelativeFilePath", () => { | ||
| 158 | it.each([ | ||
| 159 | ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], | ||
| 160 | ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], | ||
| 161 | ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], | ||
| 162 | ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], | ||
| 163 | ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"], | ||
| 164 | ["/tmp/report.ts", "/repo/project", null], | ||
| 165 | ["/repo/project-two/a.ts", "/repo/project", null], | ||
| 166 | ["/repo/project/a.ts", undefined, null], | ||
| 167 | ])("relates %s to %s", (path, workspaceRoot, relativePath) => { | ||
| 168 | expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); | ||
| 169 | }); | ||
| 170 | }); |
packages/client-runtime/src/markdownLinks.ts+197-8| ... | @@ -1,12 +1,49 @@ | ... | @@ -1,12 +1,49 @@ |
| 1 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | 2 | |
| 3 | const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; | ||
| 3 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | 4 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; |
| 5 | const RELATIVE_FILE_PATH_PATTERN = | ||
| 6 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; | ||
| 7 | const RELATIVE_FILE_NAME_PATTERN = | ||
| 8 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | ||
| 9 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | ||
| 4 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 11 | const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; | ||
| 12 | const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; | ||
| 13 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | ||
| 5 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | 14 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; |
| 6 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | 15 | const PATH_SEPARATOR_PATTERN = /[\\/]/; |
| 7 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; | 16 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; |
| 8 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; | 17 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; |
| 9 | const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; | 18 | const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; |
| 19 | // Standard OS and dev-container roots; deliberately excludes app-route-ish | ||
| 20 | // prefixes like /app/ or /chat/ so SPA routes never read as files. | ||
| 21 | const POSIX_FILE_ROOT_PREFIXES = [ | ||
| 22 | "/Users/", | ||
| 23 | "/home/", | ||
| 24 | "/tmp/", | ||
| 25 | "/var/", | ||
| 26 | "/etc/", | ||
| 27 | "/opt/", | ||
| 28 | "/mnt/", | ||
| 29 | "/Volumes/", | ||
| 30 | "/private/", | ||
| 31 | "/root/", | ||
| 32 | "/usr/", | ||
| 33 | "/bin/", | ||
| 34 | "/sbin/", | ||
| 35 | "/lib/", | ||
| 36 | "/lib64/", | ||
| 37 | "/srv/", | ||
| 38 | "/dev/", | ||
| 39 | "/proc/", | ||
| 40 | "/sys/", | ||
| 41 | "/run/", | ||
| 42 | "/boot/", | ||
| 43 | "/media/", | ||
| 44 | "/workspace/", | ||
| 45 | "/workspaces/", | ||
| 46 | ] as const; | ||
| 10 | // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. | 47 | // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. |
| 11 | const EXTENSIONLESS_FILE_NAMES = new Set([ | 48 | const EXTENSIONLESS_FILE_NAMES = new Set([ |
| 12 | "Makefile", | 49 | "Makefile", |
| ... | @@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { | ... | @@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { |
| 131 | const trimmed = codeText.trim(); | 168 | const trimmed = codeText.trim(); |
| 132 | if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; | 169 | if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; |
| 133 | 170 | ||
| 134 | const candidate = | 171 | const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/"); |
| 135 | WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) | ||
| 136 | ? trimmed | ||
| 137 | : trimmed.replaceAll("\\", "/"); | ||
| 138 | const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); | 172 | const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); |
| 139 | if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; | 173 | if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; |
| 140 | 174 | ||
| 141 | const hasExplicitPathShape = | 175 | const hasExplicitPathShape = |
| 142 | RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || | 176 | RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || |
| 143 | candidate.startsWith("/") || | 177 | candidate.startsWith("/") || |
| 144 | WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || | 178 | isWindowsAbsolutePath(candidate); |
| 145 | WINDOWS_UNC_PATH_PATTERN.test(candidate); | ||
| 146 | if (!hasExplicitPathShape) { | 179 | if (!hasExplicitPathShape) { |
| 147 | const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); | 180 | const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); |
| 148 | const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; | 181 | const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; |
| ... | @@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { | ... | @@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { |
| 156 | } | 189 | } |
| 157 | return candidate; | 190 | return candidate; |
| 158 | } | 191 | } |
| 192 | |||
| 193 | export function safeDecodeURIComponent(value: string): string { | ||
| 194 | try { | ||
| 195 | return decodeURIComponent(value); | ||
| 196 | } catch { | ||
| 197 | return value; | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | export function normalizeMarkdownLinkDestination(value: string): string { | ||
| 202 | const trimmed = value.trim(); | ||
| 203 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | ||
| 204 | } | ||
| 205 | |||
| 206 | /** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ | ||
| 207 | export function stripSlashPrefixedWindowsDrive(path: string): string { | ||
| 208 | return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; | ||
| 209 | } | ||
| 210 | |||
| 211 | export function splitMarkdownLinkSearchAndHash(value: string): { | ||
| 212 | readonly path: string; | ||
| 213 | readonly hash: string; | ||
| 214 | } { | ||
| 215 | const hashIndex = value.indexOf("#"); | ||
| 216 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; | ||
| 217 | const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; | ||
| 218 | const queryIndex = pathWithSearch.indexOf("?"); | ||
| 219 | return { | ||
| 220 | path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, | ||
| 221 | hash, | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | |||
| 225 | /** | ||
| 226 | * Turns a `file:` URL into a host path, still percent-encoded so callers that | ||
| 227 | * decode every destination in one place do not decode file URLs twice. A | ||
| 228 | * non-localhost authority becomes a UNC share. | ||
| 229 | */ | ||
| 230 | export function parseFileUrlHref( | ||
| 231 | href: string, | ||
| 232 | ): { readonly path: string; readonly hash: string } | null { | ||
| 233 | try { | ||
| 234 | const parsed = new URL(href); | ||
| 235 | if (parsed.protocol.toLowerCase() !== "file:") return null; | ||
| 236 | |||
| 237 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; | ||
| 238 | const path = uncHostname | ||
| 239 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` | ||
| 240 | : parsed.pathname; | ||
| 241 | if (path.length === 0) return null; | ||
| 242 | return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; | ||
| 243 | } catch { | ||
| 244 | return null; | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | export interface FilePathPosition { | ||
| 249 | readonly path: string; | ||
| 250 | readonly line?: number; | ||
| 251 | readonly column?: number; | ||
| 252 | } | ||
| 253 | |||
| 254 | export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { | ||
| 255 | const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); | ||
| 256 | const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); | ||
| 257 | if (!match?.[1]) return { path }; | ||
| 258 | |||
| 259 | const line = Number.parseInt(match[1], 10); | ||
| 260 | const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); | ||
| 261 | return { | ||
| 262 | path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, | ||
| 263 | ...(line > 0 ? { line } : {}), | ||
| 264 | ...(column !== undefined && column > 0 ? { column } : {}), | ||
| 265 | }; | ||
| 266 | } | ||
| 267 | |||
| 268 | export function formatFilePathPosition(position: FilePathPosition): string { | ||
| 269 | if (!position.line) return position.path; | ||
| 270 | return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; | ||
| 271 | } | ||
| 272 | |||
| 273 | export function isRelativeFilePath(path: string): boolean { | ||
| 274 | return ( | ||
| 275 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | ||
| 276 | (!path.startsWith("/") && !isWindowsAbsolutePath(path)) | ||
| 277 | ); | ||
| 278 | } | ||
| 279 | |||
| 280 | function looksLikePosixFilesystemPath(path: string): boolean { | ||
| 281 | if (!path.startsWith("/")) return false; | ||
| 282 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; | ||
| 283 | if (POSITION_SUFFIX_PATTERN.test(path)) return true; | ||
| 284 | const basename = path.slice(path.lastIndexOf("/") + 1); | ||
| 285 | return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename); | ||
| 286 | } | ||
| 287 | |||
| 288 | /** | ||
| 289 | * Decides whether a decoded link destination is a file path rather than a route | ||
| 290 | * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` | ||
| 291 | * anchor never turns `/chat/settings` into a file. | ||
| 292 | */ | ||
| 293 | function looksLikeFilePath(path: string, authoredPath: string): boolean { | ||
| 294 | if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; | ||
| 295 | if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); | ||
| 296 | if (EXTENSIONLESS_FILE_NAMES.has(path)) return true; | ||
| 297 | return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path); | ||
| 298 | } | ||
| 299 | |||
| 300 | function hasExternalScheme(path: string): boolean { | ||
| 301 | if (isWindowsAbsolutePath(path)) return false; | ||
| 302 | const match = path.match(EXTERNAL_SCHEME_PATTERN); | ||
| 303 | if (!match) return false; | ||
| 304 | const rest = match[2] ?? ""; | ||
| 305 | if (rest.startsWith("//")) return true; | ||
| 306 | return !POSITION_ONLY_PATTERN.test(rest); | ||
| 307 | } | ||
| 308 | |||
| 309 | export function parseMarkdownFileLink(href: string): FilePathPosition | null { | ||
| 310 | const normalized = normalizeMarkdownLinkDestination(href); | ||
| 311 | if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) { | ||
| 312 | return null; | ||
| 313 | } | ||
| 314 | |||
| 315 | const source = | ||
| 316 | (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ?? | ||
| 317 | splitMarkdownLinkSearchAndHash(normalized); | ||
| 318 | // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded. | ||
| 319 | const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim())); | ||
| 320 | const hash = safeDecodeURIComponent(source.hash.trim()); | ||
| 321 | if (path.length === 0 || hasExternalScheme(path)) return null; | ||
| 322 | |||
| 323 | const position = splitFilePathPosition(path, hash); | ||
| 324 | return looksLikeFilePath(position.path, path) ? position : null; | ||
| 325 | } | ||
| 326 | |||
| 327 | export function fileBasename(path: string): string { | ||
| 328 | // A trailing separator is a valid way to write a directory. Trim it before | ||
| 329 | // taking the final segment so the label is never empty. | ||
| 330 | const trimmed = path.replace(/[/\\]+$/, ""); | ||
| 331 | if (trimmed.length === 0) return path; | ||
| 332 | const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); | ||
| 333 | return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; | ||
| 334 | } | ||
| 335 | |||
| 336 | export function workspaceRelativeFilePath( | ||
| 337 | path: string, | ||
| 338 | workspaceRoot: string | null | undefined, | ||
| 339 | ): string | null { | ||
| 340 | if (!workspaceRoot) return null; | ||
| 341 | const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); | ||
| 342 | const normalizedRoot = stripSlashPrefixedWindowsDrive( | ||
| 343 | workspaceRoot.replaceAll("\\", "/"), | ||
| 344 | ).replace(/\/+$/, ""); | ||
| 345 | if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null; | ||
| 346 | return normalizedPath.slice(normalizedRoot.length + 1); | ||
| 347 | } |
packages/client-runtime/src/mediaActions.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | /** Menu action ids shared by every client so labels and handlers line up across surfaces. */ | ||
| 2 | export type MediaActionId = | ||
| 3 | | "copy-full-path" | ||
| 4 | | "copy-relative-path" | ||
| 5 | | "copy-url" | ||
| 6 | | "open-file" | ||
| 7 | | "save" | ||
| 8 | | "copy-image"; | ||
packages/client-runtime/src/mediaReference.ts+3-6| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | 2 | ||
| 3 | import { safeDecodeURIComponent } from "./markdownLinks.ts"; | ||
| 4 | |||
| 3 | /** The authored media location, never the temporary URL used to load its bytes. */ | 5 | /** The authored media location, never the temporary URL used to load its bytes. */ |
| 4 | export type MediaReference = | 6 | export type MediaReference = |
| 5 | | { | 7 | | { |
| ... | @@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde | ... | @@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde |
| 86 | } catch { | 88 | } catch { |
| 87 | return undefined; | 89 | return undefined; |
| 88 | } | 90 | } |
| 89 | if (!basename) return undefined; | 91 | return basename ? safeDecodeURIComponent(basename) : undefined; |
| 90 | try { | ||
| 91 | return decodeURIComponent(basename); | ||
| 92 | } catch { | ||
| 93 | return basename; | ||
| 94 | } | ||
| 95 | } | 92 | } |
packages/client-runtime/src/mediaSource.test.ts created+141| ... | @@ -0,0 +1,141 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { resolveMediaSource } from "./mediaSource.ts"; | ||
| 5 | |||
| 6 | const threadId = ThreadId.make("thread-1"); | ||
| 7 | const attachmentId = | ||
| 8 | "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222-mp4"; | ||
| 9 | |||
| 10 | describe("resolveMediaSource", () => { | ||
| 11 | describe("direct URLs", () => { | ||
| 12 | it("keeps the authored URL and decodes the display name once", () => { | ||
| 13 | const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; | ||
| 14 | expect(resolveMediaSource(href, { threadId })).toEqual({ | ||
| 15 | kind: "video", | ||
| 16 | mimeType: "video/mp4", | ||
| 17 | name: "clip one%20.mp4", | ||
| 18 | reference: { kind: "url", url: href }, | ||
| 19 | srcFragment: "#t=2", | ||
| 20 | access: "direct", | ||
| 21 | uri: href, | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("keeps protocol-relative URLs as authored; clients add the scheme", () => { | ||
| 26 | const href = "//cdn.example.com/clip.mp4?sig=1#t=2"; | ||
| 27 | expect(resolveMediaSource(href, { threadId })).toMatchObject({ | ||
| 28 | access: "direct", | ||
| 29 | uri: href, | ||
| 30 | reference: { kind: "url", url: href }, | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("accepts extensionless image embeds only when asked", () => { | ||
| 35 | const href = "https://cdn.example.com/render?id=42"; | ||
| 36 | expect(resolveMediaSource(href, { threadId })).toBeNull(); | ||
| 37 | expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ | ||
| 38 | kind: "image", | ||
| 39 | mimeType: "image/*", | ||
| 40 | name: "render", | ||
| 41 | }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it.each(["data:image/png;base64,AAAA", "blob:https://app.t3.codes/id"])( | ||
| 45 | "loads %s directly", | ||
| 46 | (href) => { | ||
| 47 | expect(resolveMediaSource(href, { threadId, imageEmbed: true })).toMatchObject({ | ||
| 48 | access: "direct", | ||
| 49 | uri: href, | ||
| 50 | }); | ||
| 51 | }, | ||
| 52 | ); | ||
| 53 | }); | ||
| 54 | |||
| 55 | describe("host paths", () => { | ||
| 56 | // POSIX, Windows, UNC, and file URLs must all reach the same media-file resource. | ||
| 57 | it.each([ | ||
| 58 | ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], | ||
| 59 | ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], | ||
| 60 | ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], | ||
| 61 | ["C:\\Users\\demo\\frame.png", "C:\\Users\\demo\\frame.png"], | ||
| 62 | ["/C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], | ||
| 63 | ["file:///C:/Users/demo/frame.png", "C:/Users/demo/frame.png"], | ||
| 64 | ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], | ||
| 65 | ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], | ||
| 66 | ])("resolves %s through the environment", (href, path) => { | ||
| 67 | expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toEqual({ | ||
| 68 | kind: "image", | ||
| 69 | mimeType: "image/png", | ||
| 70 | name: path.split(/[\\/]/).at(-1), | ||
| 71 | reference: { kind: "file", path }, | ||
| 72 | srcFragment: "", | ||
| 73 | access: "environment", | ||
| 74 | resource: { _tag: "media-file", threadId, path }, | ||
| 75 | }); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("joins relative paths to the workspace and records the relative reference", () => { | ||
| 79 | expect( | ||
| 80 | resolveMediaSource("screens/logo.svg?v=2#mark", { threadId, workspaceRoot: "/repo" }), | ||
| 81 | ).toEqual({ | ||
| 82 | kind: "image", | ||
| 83 | mimeType: "image/svg+xml", | ||
| 84 | name: "logo.svg", | ||
| 85 | reference: { | ||
| 86 | kind: "file", | ||
| 87 | path: "/repo/screens/logo.svg", | ||
| 88 | relativePath: "screens/logo.svg", | ||
| 89 | }, | ||
| 90 | srcFragment: "#mark", | ||
| 91 | access: "environment", | ||
| 92 | resource: { _tag: "media-file", threadId, path: "/repo/screens/logo.svg" }, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("prefers a caller-resolved path over classifying the source", () => { | ||
| 97 | expect( | ||
| 98 | resolveMediaSource("logo.svg", { | ||
| 99 | threadId, | ||
| 100 | workspaceRoot: "/repo", | ||
| 101 | resolvedFilePath: "/repo/docs/logo.svg", | ||
| 102 | }), | ||
| 103 | ).toMatchObject({ | ||
| 104 | access: "environment", | ||
| 105 | resource: { _tag: "media-file", path: "/repo/docs/logo.svg" }, | ||
| 106 | reference: { relativePath: "docs/logo.svg" }, | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("separates a playback fragment from literal filename characters", () => { | ||
| 111 | expect(resolveMediaSource("/tmp/clip%23one.mp4#t=2", { threadId })).toMatchObject({ | ||
| 112 | kind: "video", | ||
| 113 | srcFragment: "#t=2", | ||
| 114 | resource: { path: "/tmp/clip#one.mp4" }, | ||
| 115 | }); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("cannot be loaded without a thread to mint through", () => { | ||
| 119 | expect(resolveMediaSource("/tmp/clip.mp4", { threadId: undefined })).toMatchObject({ | ||
| 120 | kind: "video", | ||
| 121 | access: "unavailable", | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it.each(["notes.txt", "/repo/README", "/repo/archive.zip"])( | ||
| 126 | "returns null for the non-media path %s", | ||
| 127 | (href) => { | ||
| 128 | expect(resolveMediaSource(href, { threadId, workspaceRoot: "/repo" })).toBeNull(); | ||
| 129 | }, | ||
| 130 | ); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("serves T3 attachment files in place like any other host path", () => { | ||
| 134 | const path = `/home/demo/.t3/userdata/attachments/${attachmentId}.mp4`; | ||
| 135 | expect(resolveMediaSource(path, { threadId, workspaceRoot: "/repo" })).toMatchObject({ | ||
| 136 | kind: "video", | ||
| 137 | access: "environment", | ||
| 138 | resource: { _tag: "media-file", threadId, path }, | ||
| 139 | }); | ||
| 140 | }); | ||
| 141 | }); | ||
packages/client-runtime/src/mediaSource.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import type { AssetResource, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | classifyMarkdownImageSource, | ||
| 6 | markdownImageSourceFragment, | ||
| 7 | type MarkdownImageSource, | ||
| 8 | } from "./markdownImages.ts"; | ||
| 9 | import { | ||
| 10 | fileBasename, | ||
| 11 | splitFilePathPosition, | ||
| 12 | splitMarkdownLinkSearchAndHash, | ||
| 13 | } from "./markdownLinks.ts"; | ||
| 14 | import { | ||
| 15 | mediaFileReference, | ||
| 16 | mediaReferenceFileName, | ||
| 17 | mediaUrlReference, | ||
| 18 | type MediaReference, | ||
| 19 | } from "./mediaReference.ts"; | ||
| 20 | |||
| 21 | export type MediaSourceResource = Extract<AssetResource, { readonly _tag: "media-file" }>; | ||
| 22 | |||
| 23 | /** What a piece of authored media is and how its bytes can be reached. */ | ||
| 24 | export type ResolvedMediaSource = { | ||
| 25 | readonly kind: "image" | "video"; | ||
| 26 | readonly mimeType: string; | ||
| 27 | readonly name: string; | ||
| 28 | readonly reference?: MediaReference; | ||
| 29 | readonly srcFragment: string; | ||
| 30 | } & ( | ||
| 31 | | { | ||
| 32 | readonly access: "direct"; | ||
| 33 | readonly uri: string; | ||
| 34 | } | ||
| 35 | | { | ||
| 36 | readonly access: "environment"; | ||
| 37 | readonly resource: MediaSourceResource; | ||
| 38 | } | ||
| 39 | | { | ||
| 40 | readonly access: "unavailable"; | ||
| 41 | } | ||
| 42 | ); | ||
| 43 | |||
| 44 | export interface ResolveMediaSourceInput { | ||
| 45 | readonly threadId: ThreadId | undefined; | ||
| 46 | readonly workspaceRoot?: string | null | undefined; | ||
| 47 | readonly resolvedFilePath?: string | undefined; | ||
| 48 | /** Image syntax can target an endpoint without a recognizable extension. */ | ||
| 49 | readonly imageEmbed?: boolean | undefined; | ||
| 50 | } | ||
| 51 | |||
| 52 | function classify(source: string, input: ResolveMediaSourceInput): MarkdownImageSource { | ||
| 53 | return input.resolvedFilePath === undefined | ||
| 54 | ? classifyMarkdownImageSource(source, input.workspaceRoot) | ||
| 55 | : { _tag: "WorkspaceFile", path: input.resolvedFilePath }; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function resolveMediaSource( | ||
| 59 | source: string, | ||
| 60 | input: ResolveMediaSourceInput, | ||
| 61 | ): ResolvedMediaSource | null { | ||
| 62 | const classified = classify(source, input); | ||
| 63 | if (classified._tag === "Blocked") return null; | ||
| 64 | |||
| 65 | const path = | ||
| 66 | classified._tag === "Direct" | ||
| 67 | ? splitMarkdownLinkSearchAndHash(classified.uri).path | ||
| 68 | : splitFilePathPosition(classified.path).path; | ||
| 69 | // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. | ||
| 70 | const basename = fileBasename(path); | ||
| 71 | const extensionIndex = basename.lastIndexOf("."); | ||
| 72 | const detectedMimeType = | ||
| 73 | classified._tag === "Direct" | ||
| 74 | ? mediaMimeType(classified.uri) | ||
| 75 | : extensionIndex < 0 | ||
| 76 | ? null | ||
| 77 | : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); | ||
| 78 | const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); | ||
| 79 | if (mimeType === null) return null; | ||
| 80 | const kind = mimeType.startsWith("video/") ? "video" : "image"; | ||
| 81 | |||
| 82 | const reference = | ||
| 83 | classified._tag === "Direct" | ||
| 84 | ? mediaUrlReference(classified.uri) | ||
| 85 | : mediaFileReference(path, input.workspaceRoot); | ||
| 86 | const name = (reference && mediaReferenceFileName(reference)) || basename || kind; | ||
| 87 | const common = { | ||
| 88 | kind, | ||
| 89 | mimeType, | ||
| 90 | name, | ||
| 91 | ...(reference ? { reference } : {}), | ||
| 92 | srcFragment: markdownImageSourceFragment(source), | ||
| 93 | } as const; | ||
| 94 | |||
| 95 | if (classified._tag === "Direct") { | ||
| 96 | return { ...common, access: "direct", uri: classified.uri }; | ||
| 97 | } | ||
| 98 | if (input.threadId === undefined) return { ...common, access: "unavailable" }; | ||
| 99 | return { | ||
| 100 | ...common, | ||
| 101 | access: "environment", | ||
| 102 | resource: { _tag: "media-file", threadId: input.threadId, path }, | ||
| 103 | }; | ||
| 104 | } | ||
packages/client-runtime/src/state/assets.ts+36-2| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; | 1 | import { |
| 2 | type AssetCreateUrlResult, | ||
| 3 | AssetResource, | ||
| 4 | EnvironmentId, | ||
| 5 | WS_METHODS, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 3 | import { Atom } from "effect/unstable/reactivity"; | 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 4 | 9 | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 10 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 6 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | 11 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| ... | @@ -43,6 +48,35 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin | ... | @@ -43,6 +48,35 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin |
| 43 | } | 48 | } |
| 44 | } | 49 | } |
| 45 | 50 | ||
| 51 | export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 52 | Atom.withLabel("asset-url:empty"), | ||
| 53 | ); | ||
| 54 | |||
| 55 | export type AssetUrlState = | ||
| 56 | | { readonly _tag: "Loading" } | ||
| 57 | | { readonly _tag: "Failure" } | ||
| 58 | | { | ||
| 59 | readonly _tag: "Success"; | ||
| 60 | readonly url: string; | ||
| 61 | /** The host path the server chose to serve, when it differs from what was asked for. */ | ||
| 62 | readonly sourcePath?: string; | ||
| 63 | }; | ||
| 64 | |||
| 65 | export function assetUrlStateFromResult( | ||
| 66 | result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>, | ||
| 67 | httpBaseUrl: string | null, | ||
| 68 | ): AssetUrlState { | ||
| 69 | if (result._tag === "Failure") return { _tag: "Failure" }; | ||
| 70 | if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" }; | ||
| 71 | const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); | ||
| 72 | if (url === null) return { _tag: "Failure" }; | ||
| 73 | return { | ||
| 74 | _tag: "Success", | ||
| 75 | url, | ||
| 76 | ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 46 | export function createAssetEnvironmentAtoms<R, E>( | 80 | export function createAssetEnvironmentAtoms<R, E>( |
| 47 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | 81 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 48 | ) { | 82 | ) { |
packages/client-runtime/src/work-log/presentation.test.ts+5-11| ... | @@ -353,17 +353,11 @@ describe("toolGroupAction", () => { | ... | @@ -353,17 +353,11 @@ describe("toolGroupAction", () => { |
| 353 | describe("resolveViewedImageAsset", () => { | 353 | describe("resolveViewedImageAsset", () => { |
| 354 | const threadId = ThreadId.make("thread-1"); | 354 | const threadId = ThreadId.make("thread-1"); |
| 355 | 355 | ||
| 356 | it("loads t3 attachment paths as attachments", () => { | 356 | it("serves t3 attachment paths in place like any other host path", () => { |
| 357 | const attachmentId = | 357 | const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png"; |
| 358 | "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; | 358 | expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({ |
| 359 | expect( | 359 | resource: { _tag: "media-file", threadId, path }, |
| 360 | resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { | 360 | alt: "11111111-1111-4111-8111-111111111111.png", |
| 361 | threadId, | ||
| 362 | workspaceRoot: "/workspace", | ||
| 363 | }), | ||
| 364 | ).toEqual({ | ||
| 365 | resource: { _tag: "attachment", attachmentId }, | ||
| 366 | alt: `${attachmentId}.png`, | ||
| 367 | srcFragment: "", | 361 | srcFragment: "", |
| 368 | }); | 362 | }); |
| 369 | }); | 363 | }); |
packages/client-runtime/src/work-log/presentation.ts+13-21| ... | @@ -4,10 +4,8 @@ import { | ... | @@ -4,10 +4,8 @@ import { |
| 4 | type ThreadId, | 4 | type ThreadId, |
| 5 | type ToolLifecycleItemType, | 5 | type ToolLifecycleItemType, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 8 | classifyMarkdownImageSource, | 8 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 9 | markdownImageSourceFragment, | ||
| 10 | } from "@t3tools/client-runtime/markdown-images"; | ||
| 11 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | 9 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 12 | 10 | ||
| 13 | export function isWorktreeSetupActivity(kind: string): boolean { | 11 | export function isWorktreeSetupActivity(kind: string): boolean { |
| ... | @@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin | ... | @@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin |
| 350 | } | 348 | } |
| 351 | 349 | ||
| 352 | export interface ViewedImageAsset { | 350 | export interface ViewedImageAsset { |
| 353 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; | 351 | readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>; |
| 354 | readonly alt: string; | 352 | readonly alt: string; |
| 355 | readonly srcFragment: string; | 353 | readonly srcFragment: string; |
| 356 | } | 354 | } |
| 357 | 355 | ||
| 358 | const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; | ||
| 359 | const T3_ATTACHMENT_IMAGE_PATH_PATTERN = | ||
| 360 | /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; | ||
| 361 | |||
| 362 | export function resolveViewedImageAsset( | 356 | export function resolveViewedImageAsset( |
| 363 | source: string, | 357 | source: string, |
| 364 | input: { | 358 | input: { |
| ... | @@ -366,24 +360,22 @@ export function resolveViewedImageAsset( | ... | @@ -366,24 +360,22 @@ export function resolveViewedImageAsset( |
| 366 | readonly workspaceRoot?: string | null | undefined; | 360 | readonly workspaceRoot?: string | null | undefined; |
| 367 | }, | 361 | }, |
| 368 | ): ViewedImageAsset | null { | 362 | ): ViewedImageAsset | null { |
| 363 | // A relative path with no known workspace still names a media-file relative | ||
| 364 | // to the thread's workspace, so classify against "." and drop the prefix. | ||
| 369 | const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); | 365 | const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); |
| 370 | if (imageSource._tag !== "WorkspaceFile") return null; | 366 | if (imageSource._tag !== "WorkspaceFile") return null; |
| 371 | 367 | const resolvedFilePath = | |
| 372 | const path = | ||
| 373 | input.workspaceRoot == null && imageSource.path.startsWith("./") | 368 | input.workspaceRoot == null && imageSource.path.startsWith("./") |
| 374 | ? imageSource.path.slice(2) | 369 | ? imageSource.path.slice(2) |
| 375 | : imageSource.path; | 370 | : imageSource.path; |
| 376 | const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) | ||
| 377 | ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) | ||
| 378 | : null; | ||
| 379 | 371 | ||
| 380 | return { | 372 | const media = resolveMediaSource(source, { |
| 381 | resource: attachmentId | 373 | threadId: input.threadId, |
| 382 | ? { _tag: "attachment", attachmentId } | 374 | workspaceRoot: input.workspaceRoot, |
| 383 | : { _tag: "media-file", threadId: input.threadId, path }, | 375 | resolvedFilePath, |
| 384 | alt: path.split(/[\\/]/).at(-1) ?? "image", | 376 | }); |
| 385 | srcFragment: markdownImageSourceFragment(source), | 377 | if (media === null || media.access !== "environment") return null; |
| 386 | }; | 378 | return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment }; |
| 387 | } | 379 | } |
| 388 | 380 | ||
| 389 | function toolGroupActionCount( | 381 | function toolGroupActionCount( |