| 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 | 1 | import { |
| 2 | fileBasename, | |
| 3 | formatFilePathPosition, | |
| 2 | 4 | inlineCodeFilePathCandidate, |
| 3 | isConventionalFilePosition, | |
| 5 | normalizeMarkdownLinkDestination, | |
| 6 | parseMarkdownFileLink, | |
| 4 | 7 | } from "@t3tools/client-runtime/markdown-links"; |
| 5 | 8 | import { videoMimeType } from "@t3tools/shared/video"; |
| 6 | 9 | |
| 7 | 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 | 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 | 14 | export type MarkdownLinkPresentation = |
| 31 | 15 | | { |
| ... | ... | @@ -246,112 +230,13 @@ const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { |
| 246 | 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 | 233 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ |
| 263 | 234 | export function normalizeNativeMarkdownUrl(value: string): string { |
| 264 | 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 | 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 | 240 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; |
| 356 | 241 | const exactIcon = FILE_ICON_BY_NAME[basename]; |
| 357 | 242 | if (exactIcon) return exactIcon; |
| ... | ... | @@ -367,7 +252,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 367 | 252 | } |
| 368 | 253 | |
| 369 | 254 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { |
| 370 | const normalized = normalizeDestination(href); | |
| 255 | const normalized = normalizeMarkdownLinkDestination(href); | |
| 371 | 256 | try { |
| 372 | 257 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); |
| 373 | 258 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { |
| ... | ... | @@ -381,31 +266,16 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 381 | 266 | // Relative paths and non-URL link destinations are handled below. |
| 382 | 267 | } |
| 383 | 268 | |
| 384 | const source = normalized.toLowerCase().startsWith("file:") | |
| 385 | ? fileUrlTarget(normalized) | |
| 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)) { | |
| 269 | const target = parseMarkdownFileLink(normalized); | |
| 270 | if (target) { | |
| 401 | 271 | return { |
| 402 | 272 | kind: "file", |
| 403 | 273 | href: normalized, |
| 404 | icon: resolveMarkdownFileIcon(fileTarget.path), | |
| 405 | label: fileLabel(targetWithPosition), | |
| 406 | path: fileTarget.path, | |
| 407 | ...(fileTarget.line ? { line: fileTarget.line } : {}), | |
| 408 | ...(fileTarget.column ? { column: fileTarget.column } : {}), | |
| 274 | icon: resolveMarkdownFileIcon(target.path), | |
| 275 | label: fileBasename(formatFilePathPosition(target)), | |
| 276 | path: target.path, | |
| 277 | ...(target.line ? { line: target.line } : {}), | |
| 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 | 144 | type = detectedType |
| 145 | 145 | } else if CGPDFDocument(download as CFURL) != nil { |
| 146 | 146 | type = .pdf |
| 147 | } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { | |
| 148 | type = .svg | |
| 147 | 149 | } else { |
| 148 | 150 | throw URLError(.cannotDecodeContentData) |
| 149 | 151 | } |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+12-43| ... | ... | @@ -1,12 +1,12 @@ |
| 1 | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useEffect, useRef, useState } from "react"; | |
| 4 | import { Alert, Image, Pressable, ScrollView, View } from "react-native"; | |
| 3 | import { useMemo } from "react"; | |
| 4 | import { Image, Pressable, ScrollView, View } from "react-native"; | |
| 5 | 5 | |
| 6 | 6 | import { AppText as Text } from "./AppText"; |
| 7 | 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 9 | import type { MediaActionsSource } from "../lib/mediaActions"; | |
| 10 | 10 | import { PresentationSource } from "./NativePresentation"; |
| 11 | 11 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 12 | 12 | import { isPdfFile } from "../lib/filePreview"; |
| ... | ... | @@ -175,46 +175,16 @@ function ComposerVideoAttachment(props: { |
| 175 | 175 | const { attachment } = props; |
| 176 | 176 | const sourceIdentifier = `draft:${attachment.id}`; |
| 177 | 177 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 178 | const shareRef = useRef<AbortController | null>(null); | |
| 179 | const [sharing, setSharing] = useState(false); | |
| 180 | useEffect( | |
| 181 | () => () => { | |
| 182 | shareRef.current?.abort(); | |
| 183 | shareRef.current = null; | |
| 184 | }, | |
| 185 | [], | |
| 178 | const actionsSource = useMemo<MediaActionsSource>( | |
| 179 | () => ({ | |
| 180 | name: attachment.name, | |
| 181 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, | |
| 182 | sourceIdentifier, | |
| 183 | attachment, | |
| 184 | }), | |
| 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 | 188 | return ( |
| 219 | 189 | <VideoAttachmentTile |
| 220 | 190 | name={attachment.name} |
| ... | ... | @@ -222,8 +192,7 @@ function ComposerVideoAttachment(props: { |
| 222 | 192 | thumbnailSource={attachment} |
| 223 | 193 | compact={props.compact} |
| 224 | 194 | onPress={() => props.onPressVideo(attachment, sourceIdentifier)} |
| 225 | onShare={onShare} | |
| 226 | disabled={sharing} | |
| 195 | actionsSource={actionsSource} | |
| 227 | 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 | 3 | import { Alert } from "react-native"; |
| 4 | 4 | |
| 5 | 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; |
| 6 | import { MediaImagePreview } from "./MediaImagePreview"; | |
| 7 | 6 | |
| 8 | 7 | const NativeControls = requireNativeModule<{ |
| 9 | 8 | presentFile( |
| ... | ... | @@ -47,9 +46,5 @@ export function FilePreview(props: { |
| 47 | 46 | readonly source: ResolvedFilePreviewSource; |
| 48 | 47 | readonly onRequestClose: () => void; |
| 49 | 48 | }) { |
| 50 | return props.source.kind === "image" && props.source.actionsSource ? ( | |
| 51 | <MediaImagePreview {...props} /> | |
| 52 | ) : ( | |
| 53 | <NativeFilePreview {...props} /> | |
| 54 | ); | |
| 49 | return <NativeFilePreview {...props} />; | |
| 55 | 50 | } |
apps/mobile/src/components/MediaActionsMenu.tsx+3-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { MenuView } from "@react-native-menu/menu"; |
| 2 | 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 | 5 | import type { useMediaActions } from "../lib/mediaActions"; |
| 6 | 6 | import { SymbolView } from "./AppSymbol"; |
| ... | ... | @@ -10,6 +10,7 @@ export function MediaActionsMenu(props: { |
| 10 | 10 | readonly media: ReturnType<typeof useMediaActions>; |
| 11 | 11 | readonly inModal?: boolean; |
| 12 | 12 | readonly children?: ReactElement<PressableProps>; |
| 13 | readonly style?: StyleProp<ViewStyle>; | |
| 13 | 14 | }) { |
| 14 | 15 | if (props.media.actions.length === 0) return props.children ?? null; |
| 15 | 16 | // Android's normal anchored menu lives in the app-root portal, behind native modals. |
| ... | ... | @@ -18,6 +19,7 @@ export function MediaActionsMenu(props: { |
| 18 | 19 | return ( |
| 19 | 20 | <Menu |
| 20 | 21 | title={props.media.title} |
| 22 | style={props.style} | |
| 21 | 23 | shouldOpenOnLongPress={props.children !== undefined} |
| 22 | 24 | actions={props.media.actions.map(({ id, title, disabled }) => ({ |
| 23 | 25 | id, |
apps/mobile/src/components/MediaImagePreview.tsx+1-1| ... | ... | @@ -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 | 46 | export function MediaImagePreview(props: MediaImagePreviewProps) { |
| 47 | 47 | return ( |
| 48 | 48 | <ImagePreviewContext value={props}> |
apps/mobile/src/components/MediaVideoPlayer.tsx+35-46| ... | ... | @@ -101,8 +101,8 @@ interface MediaVideoPlayerProps { |
| 101 | 101 | readonly thumbnailVisible?: boolean; |
| 102 | 102 | readonly unavailable?: boolean; |
| 103 | 103 | readonly expanded?: boolean; |
| 104 | readonly autoPlay?: boolean; | |
| 104 | 105 | readonly paused?: boolean; |
| 105 | readonly onExpand?: () => void; | |
| 106 | 106 | readonly actionsSource?: MediaActionsSource; |
| 107 | 107 | } |
| 108 | 108 | |
| ... | ... | @@ -122,62 +122,51 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { |
| 122 | 122 | <LoadedMediaVideo |
| 123 | 123 | uri={props.uri ?? playbackUri} |
| 124 | 124 | resolvePlaybackUri={props.resolvePlaybackUri} |
| 125 | playRequested={!props.expanded} | |
| 125 | playRequested={!props.expanded || props.autoPlay === true} | |
| 126 | 126 | paused={(props.paused ?? false) || mediaActions.sharing} |
| 127 | 127 | /> |
| 128 | 128 | ) : ( |
| 129 | <Pressable | |
| 130 | accessibilityRole="button" | |
| 131 | accessibilityLabel={`Play ${props.name}`} | |
| 132 | accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} | |
| 133 | disabled={props.uri === null || props.unavailable === true} | |
| 134 | onPress={() => setPlaybackUri(props.uri)} | |
| 135 | className="flex-1 items-center justify-center gap-2 px-4" | |
| 136 | > | |
| 137 | {!props.unavailable ? ( | |
| 138 | <VideoThumbnailImage | |
| 139 | cacheKey={props.thumbnailKey} | |
| 140 | source={props.thumbnailVisible === false ? null : props.uri} | |
| 141 | contentFit="contain" | |
| 142 | /> | |
| 143 | ) : null} | |
| 144 | {props.unavailable ? ( | |
| 145 | <AppText className="text-sm text-white/80">Video unavailable</AppText> | |
| 146 | ) : props.uri === null ? ( | |
| 147 | <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> | |
| 148 | ) : ( | |
| 149 | <> | |
| 129 | <MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}> | |
| 130 | <Pressable | |
| 131 | accessibilityRole="button" | |
| 132 | accessibilityLabel={`Play ${props.name}`} | |
| 133 | accessibilityHint={ | |
| 134 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined | |
| 135 | } | |
| 136 | accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} | |
| 137 | // Stays pressable so the long-press menu still opens on a failed or unsigned tile. | |
| 138 | onPress={() => { | |
| 139 | if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); | |
| 140 | }} | |
| 141 | className="flex-1 items-center justify-center px-4" | |
| 142 | > | |
| 143 | {!props.unavailable ? ( | |
| 144 | <VideoThumbnailImage | |
| 145 | cacheKey={props.thumbnailKey} | |
| 146 | source={props.thumbnailVisible === false ? null : props.uri} | |
| 147 | contentFit="contain" | |
| 148 | /> | |
| 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 | 155 | <View className="size-12 items-center justify-center rounded-full bg-black/60"> |
| 151 | 156 | <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" /> |
| 152 | 157 | </View> |
| 158 | )} | |
| 159 | {props.uri !== null && !props.unavailable ? ( | |
| 153 | 160 | <AppText |
| 154 | className="rounded bg-black/60 px-2 py-1 text-center text-xs text-white" | |
| 155 | numberOfLines={2} | |
| 161 | className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white" | |
| 162 | numberOfLines={1} | |
| 156 | 163 | > |
| 157 | 164 | {props.name} |
| 158 | 165 | </AppText> |
| 159 | </> | |
| 160 | )} | |
| 161 | </Pressable> | |
| 166 | ) : null} | |
| 167 | </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 | 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 | 2 | |
| 3 | 3 | import { cn } from "../lib/cn"; |
| 4 | 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 5 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 5 | 6 | import { SymbolView } from "./AppSymbol"; |
| 6 | 7 | import { AppText } from "./AppText"; |
| 7 | import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; | |
| 8 | import { MediaActionsMenu } from "./MediaActionsMenu"; | |
| 9 | import { PresentationSource } from "./NativePresentation"; | |
| 8 | 10 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; |
| 9 | 11 | |
| 10 | 12 | export function VideoAttachmentTile(props: { |
| 11 | 13 | readonly name: string; |
| 12 | 14 | readonly sourceIdentifier: string; |
| 13 | 15 | readonly thumbnailSource: string | DraftComposerFileAttachment | null; |
| 16 | readonly actionsSource?: MediaActionsSource; | |
| 14 | 17 | readonly compact?: boolean; |
| 15 | 18 | readonly onPress: (sourceIdentifier: string) => void; |
| 16 | readonly onShare?: () => void; | |
| 17 | 19 | readonly disabled?: boolean; |
| 18 | 20 | readonly className?: string; |
| 19 | 21 | readonly style?: StyleProp<ViewStyle>; |
| 20 | 22 | }) { |
| 23 | const mediaActions = useMediaActions(props.disabled ? undefined : props.actionsSource); | |
| 24 | const hasActions = mediaActions.actions.length > 0; | |
| 21 | 25 | return ( |
| 22 | <VideoAttachmentMenu | |
| 23 | sourceIdentifier={props.sourceIdentifier} | |
| 24 | onOpen={() => props.onPress(props.sourceIdentifier)} | |
| 25 | onShare={props.onShare} | |
| 26 | disabled={props.disabled} | |
| 26 | <PresentationSource | |
| 27 | identifier={props.sourceIdentifier} | |
| 28 | accessible={Platform.OS === "ios"} | |
| 29 | accessibilityRole="button" | |
| 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 | |
| 29 | accessibilityRole="button" | |
| 30 | accessibilityLabel={`Play ${props.name}`} | |
| 31 | accessibilityHint={ | |
| 32 | Platform.OS === "ios" && props.onShare | |
| 33 | ? "Touch and hold for save and share options" | |
| 34 | : undefined | |
| 35 | } | |
| 36 | accessibilityState={{ disabled: props.disabled ?? false }} | |
| 37 | disabled={props.disabled} | |
| 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 | )} | |
| 44 | <MediaActionsMenu media={mediaActions}> | |
| 45 | <Pressable | |
| 46 | accessibilityRole="button" | |
| 47 | accessibilityLabel={`Play ${props.name}`} | |
| 48 | accessibilityHint={hasActions ? "Touch and hold for media actions" : undefined} | |
| 49 | accessibilityState={{ disabled: props.disabled ?? false }} | |
| 50 | disabled={props.disabled} | |
| 51 | onPress={() => props.onPress(props.sourceIdentifier)} | |
| 52 | className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} | |
| 53 | style={props.style} | |
| 48 | 54 | > |
| 49 | <SymbolView | |
| 50 | name="play" | |
| 51 | size={props.compact ? 15 : 24} | |
| 52 | tintColor="#ffffff" | |
| 53 | type="monochrome" | |
| 54 | /> | |
| 55 | </View> | |
| 56 | {!props.compact ? ( | |
| 57 | <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5"> | |
| 58 | <AppText className="text-center text-xs text-white" numberOfLines={1}> | |
| 59 | {props.name} | |
| 60 | </AppText> | |
| 55 | <VideoThumbnailImage cacheKey={props.sourceIdentifier} source={props.thumbnailSource} /> | |
| 56 | <View | |
| 57 | className={cn( | |
| 58 | "items-center justify-center rounded-full bg-black/45", | |
| 59 | props.compact ? "size-6" : "size-12", | |
| 60 | )} | |
| 61 | > | |
| 62 | <SymbolView | |
| 63 | name="play" | |
| 64 | size={props.compact ? 15 : 24} | |
| 65 | tintColor="#ffffff" | |
| 66 | type="monochrome" | |
| 67 | /> | |
| 61 | 68 | </View> |
| 62 | ) : null} | |
| 63 | </Pressable> | |
| 64 | </VideoAttachmentMenu> | |
| 69 | {!props.compact ? ( | |
| 70 | <View className="absolute inset-x-0 bottom-0 bg-black/55 px-2 py-1.5"> | |
| 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 | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 3 | 2 | import { requireNativeModule } from "expo"; |
| 4 | 3 | import { useEffect, useEffectEvent, useId, useState } from "react"; |
| 5 | 4 | import { Alert, Keyboard } from "react-native"; |
| 6 | 5 | |
| 7 | 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 | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 11 | import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; | |
| 12 | 10 | |
| 13 | 11 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 14 | 12 | |
| ... | ... | @@ -23,27 +21,28 @@ const NativeControls = requireNativeModule<{ |
| 23 | 21 | }>("T3NativeControls"); |
| 24 | 22 | |
| 25 | 23 | function NativeVideoPreview(props: { |
| 26 | readonly source: AttachmentVideoPreviewSource; | |
| 24 | readonly source: VideoPreviewSource; | |
| 27 | 25 | readonly onRequestClose: () => void; |
| 28 | 26 | }) { |
| 29 | 27 | const { source } = props; |
| 30 | const { attachment } = source; | |
| 28 | const localAttachment = source.type === "local" ? source.attachment : null; | |
| 31 | 29 | const identifier = useId(); |
| 32 | 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 | 34 | const preparedConnection = usePreparedConnection(environmentId); |
| 35 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | |
| 36 | const assetUrl = useAssetUrlState( | |
| 37 | environmentId, | |
| 38 | source.type === "remote" | |
| 39 | ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } | |
| 40 | : null, | |
| 41 | ); | |
| 42 | const [playbackUrl, setPlaybackUrl] = useState<string | null>(() => | |
| 43 | assetUrl._tag === "Success" ? assetUrl.url : null, | |
| 44 | ); | |
| 35 | const assetUrl = useAssetUrlState(environmentId, resource); | |
| 36 | const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); | |
| 37 | const name = source.type === "media" ? source.name : source.attachment.name; | |
| 38 | // The first minted URL is kept so a background refresh does not restart playback. | |
| 39 | const resolvedUrl = | |
| 40 | source.type === "media" | |
| 41 | ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) | |
| 42 | : null; | |
| 43 | const [playbackUrl, setPlaybackUrl] = useState<string | null>(resolvedUrl); | |
| 45 | 44 | const loadError = |
| 46 | source.type === "remote" && playbackUrl === null | |
| 45 | resource !== null && playbackUrl === null | |
| 47 | 46 | ? preparedConnection._tag === "None" |
| 48 | 47 | ? "Reconnect to this environment and open the video again." |
| 49 | 48 | : assetUrl._tag === "Failure" |
| ... | ... | @@ -53,8 +52,8 @@ function NativeVideoPreview(props: { |
| 53 | 52 | |
| 54 | 53 | useEffect(() => Keyboard.dismiss(), []); |
| 55 | 54 | useEffect(() => { |
| 56 | if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); | |
| 57 | }, [playbackUrl, assetUrl]); | |
| 55 | if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); | |
| 56 | }, [playbackUrl, resolvedUrl]); | |
| 58 | 57 | useEffect(() => { |
| 59 | 58 | if (!loadError) return; |
| 60 | 59 | Alert.alert("Could not open video", loadError); |
| ... | ... | @@ -62,21 +61,21 @@ function NativeVideoPreview(props: { |
| 62 | 61 | }, [loadError]); |
| 63 | 62 | |
| 64 | 63 | useEffect(() => { |
| 65 | if (source.type === "remote" && playbackUrl === null) return; | |
| 64 | if (localAttachment === null && playbackUrl === null) return; | |
| 66 | 65 | const controller = new AbortController(); |
| 67 | 66 | let ready = false; |
| 68 | 67 | void (async () => { |
| 69 | 68 | const file = |
| 70 | source.type === "local" | |
| 71 | ? await loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 69 | localAttachment !== null | |
| 70 | ? await loadLocalAttachmentPreview(localAttachment, controller.signal) | |
| 72 | 71 | : null; |
| 73 | if (source.type === "local" && !file) return; | |
| 72 | if (localAttachment !== null && !file) return; | |
| 74 | 73 | try { |
| 75 | 74 | if (controller.signal.aborted) return; |
| 76 | 75 | ready = true; |
| 77 | 76 | await NativeControls.presentVideo( |
| 78 | 77 | file?.uri ?? playbackUrl!, |
| 79 | attachment.name, | |
| 78 | name, | |
| 80 | 79 | source.sourceIdentifier ?? "", |
| 81 | 80 | identifier, |
| 82 | 81 | ); |
| ... | ... | @@ -87,10 +86,12 @@ function NativeVideoPreview(props: { |
| 87 | 86 | } |
| 88 | 87 | })().catch((error: unknown) => { |
| 89 | 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 | 91 | Alert.alert( |
| 91 | 92 | "Could not open video", |
| 92 | 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 | 95 | : error instanceof Error |
| 95 | 96 | ? error.message |
| 96 | 97 | : "Could not load this video.", |
| ... | ... | @@ -101,7 +102,7 @@ function NativeVideoPreview(props: { |
| 101 | 102 | controller.abort(); |
| 102 | 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 | 107 | return null; |
| 107 | 108 | } |
| ... | ... | @@ -118,8 +119,5 @@ export function VideoPreviewModal(props: { |
| 118 | 119 | }, [isFocused, hasSource]); |
| 119 | 120 | |
| 120 | 121 | if (!props.source || !isFocused) return null; |
| 121 | if (props.source.type === "media") { | |
| 122 | return <MediaVideoPreviewModal source={props.source} onRequestClose={props.onRequestClose} />; | |
| 123 | } | |
| 124 | 122 | return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />; |
| 125 | 123 | } |
apps/mobile/src/components/VideoPreviewModal.tsx+153-180| ... | ... | @@ -1,190 +1,139 @@ |
| 1 | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useEvent } from "expo"; | |
| 4 | import { useVideoPlayer, VideoView } from "expo-video"; | |
| 5 | 3 | import { useEffect, useRef, useState } from "react"; |
| 6 | import { | |
| 7 | ActivityIndicator, | |
| 8 | AppState, | |
| 9 | Keyboard, | |
| 10 | Modal, | |
| 11 | Pressable, | |
| 12 | StyleSheet, | |
| 13 | View, | |
| 14 | } from "react-native"; | |
| 4 | import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-native"; | |
| 15 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 16 | 6 | |
| 17 | import { | |
| 18 | downloadAttachmentForPreview, | |
| 19 | type AttachmentPreviewFile, | |
| 20 | } from "../lib/attachmentDownload"; | |
| 21 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 22 | import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; | |
| 23 | import { useAssetUrlState } from "../state/assets"; | |
| 8 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 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 | 17 | import { usePreparedConnection } from "../state/session"; |
| 25 | import { SymbolView } from "./AppSymbol"; | |
| 26 | 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 | 24 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 30 | 25 | |
| 31 | function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { | |
| 32 | const player = useVideoPlayer(props.file.uri, (player) => { | |
| 33 | player.staysActiveInBackground = false; | |
| 34 | if (AppState.currentState === "active") player.play(); | |
| 35 | }); | |
| 36 | const { status } = useEvent(player, "statusChange", { status: player.status }); | |
| 37 | const shareControllerRef = useRef<AbortController | null>(null); | |
| 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 | ); | |
| 26 | interface PlaybackState { | |
| 27 | readonly uri: string | null; | |
| 28 | readonly resolvePlaybackUri?: () => Promise<string | null>; | |
| 29 | readonly unavailable: boolean; | |
| 30 | readonly error: string | null; | |
| 31 | readonly actionsSource: MediaActionsSource | undefined; | |
| 32 | } | |
| 48 | 33 | |
| 49 | const onShare = () => { | |
| 50 | if (shareControllerRef.current) return; | |
| 51 | player.pause(); | |
| 52 | const controller = new AbortController(); | |
| 53 | shareControllerRef.current = controller; | |
| 54 | setSharing(true); | |
| 55 | setShareError(null); | |
| 56 | void props.file | |
| 57 | .share(controller.signal) | |
| 58 | .catch((error: unknown) => { | |
| 59 | if (!controller.signal.aborted) { | |
| 60 | setShareError(error instanceof Error ? error.message : "Could not share this video."); | |
| 61 | } | |
| 62 | }) | |
| 63 | .finally(() => { | |
| 64 | if (shareControllerRef.current === controller) { | |
| 65 | shareControllerRef.current = null; | |
| 66 | setSharing(false); | |
| 67 | } | |
| 68 | }); | |
| 34 | function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { | |
| 35 | const environmentId = "environmentId" in source ? source.environmentId : null; | |
| 36 | const resource = "resource" in source ? source.resource : null; | |
| 37 | const connection = usePreparedConnection(environmentId); | |
| 38 | const asset = useAssetUrlState(environmentId, resource); | |
| 39 | const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); | |
| 40 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); | |
| 41 | return { | |
| 42 | uri, | |
| 43 | ...(resource !== null | |
| 44 | ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } | |
| 45 | : {}), | |
| 46 | unavailable: | |
| 47 | uri === null && | |
| 48 | environmentId !== null && | |
| 49 | (connection._tag === "None" || asset._tag === "Failure"), | |
| 50 | error: null, | |
| 51 | actionsSource: source.actionsSource, | |
| 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: { | |
| 116 | readonly source: AttachmentVideoPreviewSource; | |
| 117 | readonly onRequestClose: () => void; | |
| 118 | }) { | |
| 119 | const { source } = props; | |
| 55 | function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { | |
| 120 | 56 | const { attachment } = source; |
| 121 | const insets = useSafeAreaInsets(); | |
| 122 | const environmentId = source.type === "remote" ? source.environmentId : null; | |
| 123 | const preparedConnection = usePreparedConnection(environmentId); | |
| 124 | const fileUri = source.type === "local" ? source.attachment.fileUri : null; | |
| 125 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; | |
| 126 | const assetUrl = useAssetUrlState( | |
| 127 | environmentId, | |
| 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 | ||
| 57 | const [uri, setUri] = useState<string | null>(null); | |
| 58 | const [error, setError] = useState<string | null>(null); | |
| 59 | // Only a different file needs a new lease; a metadata update on the same | |
| 60 | // draft must not dispose the file Android is still playing. | |
| 61 | const attachmentRef = useRef(attachment); | |
| 62 | attachmentRef.current = attachment; | |
| 63 | const { id: attachmentId, fileUri } = attachment; | |
| 143 | 64 | useEffect(() => { |
| 144 | if (source.type === "remote" && downloadUrl === null) return; | |
| 65 | setUri(null); | |
| 66 | setError(null); | |
| 145 | 67 | const controller = new AbortController(); |
| 146 | let preview: AttachmentPreviewFile | null = null; | |
| 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 | }); | |
| 68 | const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); | |
| 157 | 69 | void loading.then( |
| 158 | (loaded) => { | |
| 159 | if (controller.signal.aborted) { | |
| 160 | loaded?.dispose(); | |
| 161 | return; | |
| 162 | } | |
| 163 | preview = loaded; | |
| 164 | setFile(loaded); | |
| 70 | (file) => { | |
| 71 | if (file === null) return; | |
| 72 | if (controller.signal.aborted) file.dispose(); | |
| 73 | else setUri(file.uri); | |
| 165 | 74 | }, |
| 166 | (error: unknown) => { | |
| 75 | (cause: unknown) => { | |
| 167 | 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 | 81 | return () => { |
| 173 | 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 = | |
| 179 | failure ?? | |
| 180 | (environmentId !== null && downloadUrl === null | |
| 181 | ? preparedConnection._tag === "None" | |
| 182 | ? "This environment is disconnected. Reconnect and open the video again." | |
| 183 | : assetUrl._tag === "Failure" | |
| 184 | ? "Could not load this video. Check the connection to this environment and try again." | |
| 185 | : null | |
| 186 | : null); | |
| 126 | function OpenVideoPreviewModal(props: { | |
| 127 | readonly name: string; | |
| 128 | readonly thumbnailKey: string; | |
| 129 | readonly playback: PlaybackState; | |
| 130 | readonly onRequestClose: () => void; | |
| 131 | }) { | |
| 132 | const { playback } = props; | |
| 133 | const insets = useSafeAreaInsets(); | |
| 134 | const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); | |
| 187 | 135 | |
| 136 | useEffect(() => Keyboard.dismiss(), []); | |
| 188 | 137 | return ( |
| 189 | 138 | <Modal |
| 190 | 139 | visible |
| ... | ... | @@ -200,8 +149,9 @@ function OpenVideoPreviewModal(props: { |
| 200 | 149 | > |
| 201 | 150 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> |
| 202 | 151 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> |
| 203 | {attachment.name} | |
| 152 | {props.name} | |
| 204 | 153 | </AppText> |
| 154 | <MediaActionsMenu media={mediaActions} inModal /> | |
| 205 | 155 | <Pressable |
| 206 | 156 | accessibilityRole="button" |
| 207 | 157 | accessibilityLabel="Close video" |
| ... | ... | @@ -211,22 +161,40 @@ function OpenVideoPreviewModal(props: { |
| 211 | 161 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> |
| 212 | 162 | </Pressable> |
| 213 | 163 | </View> |
| 214 | {file ? ( | |
| 215 | <VideoPlayback key={file.uri} file={file} /> | |
| 216 | ) : ( | |
| 164 | <MediaSourceCaption source={mediaActions.title} /> | |
| 165 | {playback.uri === null && !playback.unavailable ? ( | |
| 217 | 166 | <View className="flex-1 items-center justify-center gap-3 px-6"> |
| 218 | {loadError ? ( | |
| 219 | <AppText accessibilityRole="alert" className="text-center text-base text-white/80"> | |
| 220 | {loadError} | |
| 221 | </AppText> | |
| 222 | ) : ( | |
| 223 | <> | |
| 224 | <ActivityIndicator color="#ffffff" /> | |
| 225 | <AppText className="text-sm text-white/80">Loading video...</AppText> | |
| 226 | </> | |
| 227 | )} | |
| 167 | <ActivityIndicator color="#ffffff" /> | |
| 168 | <AppText className="text-sm text-white/80">Loading video...</AppText> | |
| 228 | 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 | 198 | </View> |
| 231 | 199 | </Modal> |
| 232 | 200 | ); |
| ... | ... | @@ -243,12 +211,17 @@ export function VideoPreviewModal(props: { |
| 243 | 211 | }, [isFocused, hasSource, props.onRequestClose]); |
| 244 | 212 | const { source } = props; |
| 245 | 213 | if (source === null || !isFocused) return null; |
| 246 | if (source.type === "media") { | |
| 247 | return <MediaVideoPreviewModal source={source} onRequestClose={props.onRequestClose} />; | |
| 248 | } | |
| 249 | const key = | |
| 250 | source.type === "local" | |
| 251 | ? `local:${source.attachment.id}:${source.attachment.fileUri}` | |
| 252 | : `remote:${source.environmentId}:${source.attachment.id}`; | |
| 253 | return <OpenVideoPreviewModal key={key} source={source} onRequestClose={props.onRequestClose} />; | |
| 214 | return source.type === "local" ? ( | |
| 215 | <LocalPreviewModal | |
| 216 | key={`local:${source.attachment.id}:${source.attachment.fileUri}`} | |
| 217 | source={source} | |
| 218 | onRequestClose={props.onRequestClose} | |
| 219 | /> | |
| 220 | ) : ( | |
| 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 | 668 | id: action.id, |
| 669 | 669 | title: action.title, |
| 670 | 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 | 672 | inline: false, |
| 673 | 673 | onPress: action.run, |
| 674 | 674 | })) |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+38-75| ... | ... | @@ -1,11 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | 1 | import { useId, useMemo, useState } from "react"; |
| 3 | 2 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | 3 | |
| 6 | 4 | import { AppText as Text } from "../../components/AppText"; |
| 7 | 5 | import { EmptyState } from "../../components/EmptyState"; |
| 8 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; | |
| 9 | 6 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 10 | 7 | import { PresentationSource } from "../../components/NativePresentation"; |
| 11 | 8 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; |
| ... | ... | @@ -27,86 +24,52 @@ function ResolvedWorkspaceFileImagePreview(props: { |
| 27 | 24 | |
| 28 | 25 | return ( |
| 29 | 26 | <View className="relative flex-1 bg-subtle"> |
| 30 | <Pressable | |
| 31 | accessibilityRole="button" | |
| 32 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} | |
| 33 | disabled={loadError !== null} | |
| 34 | className="flex-1 p-4 active:bg-subtle-strong" | |
| 35 | onPress={() => | |
| 36 | setPreview({ | |
| 37 | kind: "image", | |
| 38 | uri: props.uri, | |
| 39 | name: props.accessibilityLabel, | |
| 40 | sourceIdentifier, | |
| 41 | actionsSource: props.actionsSource, | |
| 42 | }) | |
| 43 | } | |
| 44 | > | |
| 45 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> | |
| 46 | <Image | |
| 47 | accessible={false} | |
| 48 | source={imageSource} | |
| 49 | className="h-full w-full" | |
| 50 | resizeMode="contain" | |
| 51 | onLoadStart={() => setLoadError(null)} | |
| 52 | onError={(event) => { | |
| 53 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 54 | }} | |
| 55 | /> | |
| 56 | </PresentationSource> | |
| 57 | </Pressable> | |
| 27 | <MediaActionsMenu media={mediaActions} style={{ flex: 1 }}> | |
| 28 | <Pressable | |
| 29 | accessibilityRole="button" | |
| 30 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} | |
| 31 | accessibilityHint={ | |
| 32 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined | |
| 33 | } | |
| 34 | disabled={loadError !== null} | |
| 35 | className="flex-1 p-4 active:bg-subtle-strong" | |
| 36 | onPress={() => | |
| 37 | setPreview({ | |
| 38 | kind: "image", | |
| 39 | uri: props.uri, | |
| 40 | name: props.accessibilityLabel, | |
| 41 | sourceIdentifier, | |
| 42 | actionsSource: props.actionsSource, | |
| 43 | }) | |
| 44 | } | |
| 45 | > | |
| 46 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> | |
| 47 | <Image | |
| 48 | accessible={false} | |
| 49 | source={imageSource} | |
| 50 | className="h-full w-full" | |
| 51 | resizeMode="contain" | |
| 52 | onLoadStart={() => setLoadError(null)} | |
| 53 | onError={(event) => { | |
| 54 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 55 | }} | |
| 56 | /> | |
| 57 | </PresentationSource> | |
| 58 | </Pressable> | |
| 59 | </MediaActionsMenu> | |
| 58 | 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 | 65 | <EmptyState title="Image unavailable" detail={loadError} /> |
| 61 | 66 | </View> |
| 62 | 67 | ) : null} |
| 63 | ||
| 64 | <View className="absolute right-2 top-2"> | |
| 65 | <MediaActionsMenu media={mediaActions} /> | |
| 66 | </View> | |
| 67 | ||
| 68 | 68 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 69 | 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 | 73 | export function WorkspaceFileImagePreview(props: { |
| 111 | 74 | readonly accessibilityLabel: string; |
| 112 | 75 | readonly uri: string | null; |
| ... | ... | @@ -124,7 +87,7 @@ export function WorkspaceFileImagePreview(props: { |
| 124 | 87 | } |
| 125 | 88 | |
| 126 | 89 | return ( |
| 127 | <CachedWorkspaceFileImagePreview | |
| 90 | <ResolvedWorkspaceFileImagePreview | |
| 128 | 91 | accessibilityLabel={props.accessibilityLabel} |
| 129 | 92 | uri={props.uri} |
| 130 | 93 | actionsSource={props.actionsSource} |
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx-7| ... | ... | @@ -1,9 +1,7 @@ |
| 1 | import { useState } from "react"; | |
| 2 | 1 | import { View } from "react-native"; |
| 3 | 2 | |
| 4 | 3 | import { EmptyState } from "../../components/EmptyState"; |
| 5 | 4 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 6 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 7 | 5 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 8 | 6 | |
| 9 | 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 | 13 | readonly resolvePlaybackUri: () => Promise<string | null>; |
| 16 | 14 | readonly unavailable: boolean; |
| 17 | 15 | }) { |
| 18 | const [preview, setPreview] = useState<VideoPreviewSource | null>(null); | |
| 19 | 16 | const uri = props.uri; |
| 20 | 17 | |
| 21 | 18 | if (props.unavailable) { |
| ... | ... | @@ -37,11 +34,7 @@ export function WorkspaceFileVideoPreview(props: { |
| 37 | 34 | name={props.name} |
| 38 | 35 | thumbnailKey={props.thumbnailKey} |
| 39 | 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 | 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 | 105 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; |
| 106 | 106 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 107 | 107 | import { |
| 108 | attachmentVideoPreviewSource, | |
| 108 | 109 | mediaVideoPreviewUri, |
| 109 | 110 | mediaVideoThumbnailKey, |
| 110 | 111 | type MediaVideoPreviewSource, |
| ... | ... | @@ -251,14 +252,21 @@ function MessageAttachmentImage(props: { |
| 251 | 252 | readonly environmentId: EnvironmentId; |
| 252 | 253 | readonly attachmentId: string; |
| 253 | 254 | readonly name: string; |
| 255 | readonly mimeType: string; | |
| 254 | 256 | readonly className: string; |
| 255 | 257 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 256 | 258 | }) { |
| 257 | 259 | const sourceIdentifier = useId(); |
| 258 | const uri = useAssetUrl(props.environmentId, { | |
| 259 | _tag: "attachment", | |
| 260 | attachmentId: props.attachmentId, | |
| 261 | }); | |
| 260 | const resource = useMemo( | |
| 261 | () => ({ | |
| 262 | _tag: "attachment" as const, | |
| 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 | 271 | if (uri === null) { |
| 264 | 272 | return ( |
| ... | ... | @@ -274,7 +282,20 @@ function MessageAttachmentImage(props: { |
| 274 | 282 | accessibilityRole="imagebutton" |
| 275 | 283 | accessibilityLabel={`Open ${props.name}`} |
| 276 | 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 | 301 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> |
| ... | ... | @@ -389,14 +410,18 @@ function MessageAttachmentFile(props: { |
| 389 | 410 | }; |
| 390 | 411 | |
| 391 | 412 | if (videoType !== null) { |
| 413 | const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; | |
| 392 | 414 | return ( |
| 393 | 415 | <VideoAttachmentTile |
| 394 | 416 | name={attachment.name} |
| 395 | sourceIdentifier={`attachment:${props.environmentId}:${attachment.id}`} | |
| 417 | sourceIdentifier={sourceIdentifier} | |
| 396 | 418 | thumbnailSource={thumbnailUrl} |
| 419 | actionsSource={ | |
| 420 | attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier) | |
| 421 | .actionsSource | |
| 422 | } | |
| 397 | 423 | disabled={opening || httpBaseUrl === null} |
| 398 | 424 | onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} |
| 399 | onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} | |
| 400 | 425 | className="my-1 rounded-2xl" |
| 401 | 426 | style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} |
| 402 | 427 | /> |
| ... | ... | @@ -520,62 +545,60 @@ function ThreadMarkdownImageView(props: { |
| 520 | 545 | style={{ alignSelf: "stretch", gap: 6 }} |
| 521 | 546 | > |
| 522 | 547 | {props.uri === null || failed ? ( |
| 523 | <View | |
| 524 | className="items-center justify-center rounded-[10px] bg-md-code-bg" | |
| 525 | style={{ | |
| 526 | ...frameStyle, | |
| 527 | }} | |
| 528 | > | |
| 529 | {failed ? ( | |
| 530 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> | |
| 531 | ) : ( | |
| 532 | <ActivityIndicator /> | |
| 533 | )} | |
| 534 | {props.actionsSource ? ( | |
| 535 | <View className="absolute right-1 top-1"> | |
| 536 | <MediaActionsMenu media={mediaActions} /> | |
| 537 | </View> | |
| 538 | ) : null} | |
| 539 | </View> | |
| 548 | <MediaActionsMenu media={mediaActions}> | |
| 549 | <Pressable | |
| 550 | accessibilityRole="imagebutton" | |
| 551 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 552 | accessibilityHint={ | |
| 553 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined | |
| 554 | } | |
| 555 | className="items-center justify-center rounded-[10px] bg-md-code-bg" | |
| 556 | style={frameStyle} | |
| 557 | > | |
| 558 | {failed ? ( | |
| 559 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> | |
| 560 | ) : ( | |
| 561 | <ActivityIndicator /> | |
| 562 | )} | |
| 563 | </Pressable> | |
| 564 | </MediaActionsMenu> | |
| 540 | 565 | ) : ( |
| 541 | 566 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 542 | <View> | |
| 543 | <MediaActionsMenu media={mediaActions}> | |
| 544 | <Pressable | |
| 545 | accessibilityRole="imagebutton" | |
| 546 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 547 | onPress={() => | |
| 548 | props.onPressPreview({ | |
| 549 | kind: "image", | |
| 550 | uri: props.uri!, | |
| 551 | name: props.alt ?? "Image", | |
| 552 | sourceIdentifier, | |
| 553 | actionsSource: props.actionsSource, | |
| 554 | }) | |
| 555 | } | |
| 556 | style={{ alignSelf: "flex-start" }} | |
| 567 | <MediaActionsMenu media={mediaActions}> | |
| 568 | <Pressable | |
| 569 | accessibilityRole="imagebutton" | |
| 570 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 571 | accessibilityHint={ | |
| 572 | mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined | |
| 573 | } | |
| 574 | onPress={() => | |
| 575 | // Quick Look picks the viewer from the name's extension, so it needs the | |
| 576 | // file name rather than the alt text. | |
| 577 | props.onPressPreview({ | |
| 578 | kind: "image", | |
| 579 | uri: props.uri!, | |
| 580 | name: props.actionsSource?.name ?? props.alt ?? "Image", | |
| 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 | |
| 559 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 560 | style={{ | |
| 561 | ...frameStyle, | |
| 562 | }} | |
| 563 | > | |
| 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} /> | |
| 593 | <ThreadMarkdownImageRequest | |
| 594 | key={props.uri} | |
| 595 | uri={props.uri} | |
| 596 | onLoad={setSourceSize} | |
| 597 | onError={() => setFailedUri(props.uri)} | |
| 598 | /> | |
| 576 | 599 | </View> |
| 577 | ) : null} | |
| 578 | </View> | |
| 600 | </Pressable> | |
| 601 | </MediaActionsMenu> | |
| 579 | 602 | </PresentationSource> |
| 580 | 603 | )} |
| 581 | 604 | {props.alt ? ( |
| ... | ... | @@ -658,10 +681,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { |
| 658 | 681 | return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; |
| 659 | 682 | } |
| 660 | 683 | |
| 661 | function ThreadMarkdownVideo(props: { | |
| 662 | readonly source: MediaVideoPreviewSource; | |
| 663 | readonly onExpand: (source: MediaVideoPreviewSource) => void; | |
| 664 | }) { | |
| 684 | function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { | |
| 665 | 685 | const { source } = props; |
| 666 | 686 | const visible = useContext(ThreadMediaVisibleContext); |
| 667 | 687 | const thumbnailKey = mediaVideoThumbnailKey(source); |
| ... | ... | @@ -688,7 +708,6 @@ function ThreadMarkdownVideo(props: { |
| 688 | 708 | thumbnailVisible={visible} |
| 689 | 709 | unavailable={"resource" in source && asset._tag === "Failure"} |
| 690 | 710 | actionsSource={source.actionsSource} |
| 691 | onExpand={() => props.onExpand(source)} | |
| 692 | 711 | /> |
| 693 | 712 | ); |
| 694 | 713 | } |
| ... | ... | @@ -1593,6 +1612,7 @@ function renderFeedEntry( |
| 1593 | 1612 | environmentId={props.environmentId} |
| 1594 | 1613 | attachmentId={attachment.id} |
| 1595 | 1614 | name={attachment.name} |
| 1615 | mimeType={attachment.mimeType} | |
| 1596 | 1616 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1597 | 1617 | onPressPreview={props.onPressPreview} |
| 1598 | 1618 | /> |
| ... | ... | @@ -1656,6 +1676,7 @@ function renderFeedEntry( |
| 1656 | 1676 | environmentId={props.environmentId} |
| 1657 | 1677 | attachmentId={attachment.id} |
| 1658 | 1678 | name={attachment.name} |
| 1679 | mimeType={attachment.mimeType} | |
| 1659 | 1680 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" |
| 1660 | 1681 | onPressPreview={props.onPressPreview} |
| 1661 | 1682 | /> |
| ... | ... | @@ -2218,7 +2239,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2218 | 2239 | <ThreadMarkdownVideo |
| 2219 | 2240 | key={image.href} |
| 2220 | 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 | 2687 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { |
| 2668 | 2688 | setExpandedVideo( |
| 2669 | 2689 | (current) => |
| 2670 | current ?? { | |
| 2671 | type: "remote", | |
| 2672 | environmentId: props.environmentId, | |
| 2673 | attachment, | |
| 2674 | sourceIdentifier, | |
| 2675 | }, | |
| 2690 | current ?? | |
| 2691 | attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), | |
| 2676 | 2692 | ); |
| 2677 | 2693 | }, |
| 2678 | 2694 | [props.environmentId], |
apps/mobile/src/lib/markdownMedia.test.ts+11| ... | ... | @@ -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 | 75 | it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { |
| 65 | 76 | expect( |
| 66 | 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 | import { | |
| 2 | classifyMarkdownImageSource, | |
| 3 | markdownImageSourceFragment, | |
| 4 | } from "@t3tools/client-runtime/markdown-images"; | |
| 1 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | |
| 5 | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 6 | 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 | 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; |
| 15 | 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; |
| ... | ... | @@ -29,61 +20,30 @@ export function resolveMarkdownMediaPreview( |
| 29 | 20 | | { readonly kind: "image"; readonly source: FilePreviewSource } |
| 30 | 21 | | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } |
| 31 | 22 | | null { |
| 32 | const classified = classifyMarkdownImageSource(href, input.workspaceRoot); | |
| 33 | if (classified._tag === "Blocked") return null; | |
| 34 | const path = | |
| 35 | classified._tag === "WorkspaceFile" | |
| 36 | ? classified.path.replace(/:\d+(?::\d+)?$/, "") | |
| 37 | : classified.uri.split(/[?#]/, 1)[0]!; | |
| 38 | const basename = path.split(/[\\/]/).at(-1) ?? ""; | |
| 39 | const extensionIndex = basename.lastIndexOf("."); | |
| 40 | // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. | |
| 41 | const detectedMimeType = | |
| 42 | classified._tag === "Direct" | |
| 43 | ? mediaMimeType(classified.uri) | |
| 44 | : extensionIndex < 0 | |
| 45 | ? null | |
| 46 | : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); | |
| 47 | const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); | |
| 48 | if (mimeType === null) return null; | |
| 49 | const kind = mimeType.startsWith("video/") ? "video" : "image"; | |
| 50 | const reference = | |
| 51 | classified._tag === "Direct" | |
| 52 | ? mediaUrlReference(classified.uri) | |
| 53 | : mediaFileReference(path, input.workspaceRoot); | |
| 54 | const name = | |
| 55 | (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); | |
| 56 | const srcFragment = markdownImageSourceFragment(href); | |
| 23 | const media = resolveMediaSource(href, input); | |
| 24 | if (media === null || media.access === "unavailable") return null; | |
| 25 | const { kind, name, mimeType, reference, srcFragment } = media; | |
| 26 | ||
| 57 | 27 | const target = |
| 58 | classified._tag === "Direct" | |
| 59 | ? { uri: normalizeNativeMarkdownUrl(classified.uri) } | |
| 28 | media.access === "direct" | |
| 29 | ? { uri: normalizeNativeMarkdownUrl(media.uri) } | |
| 60 | 30 | : { |
| 61 | 31 | environmentId: input.environmentId, |
| 62 | resource: { | |
| 63 | _tag: "media-file" as const, | |
| 64 | threadId: input.threadId, | |
| 65 | path, | |
| 66 | }, | |
| 32 | resource: media.resource, | |
| 67 | 33 | ...(srcFragment ? { srcFragment } : {}), |
| 68 | 34 | }; |
| 69 | 35 | const actionsSource: MediaActionsSource = |
| 70 | classified._tag === "Direct" | |
| 71 | ? { reference, uri: classified.uri, name, mimeType } | |
| 36 | media.access === "direct" | |
| 37 | ? { reference, uri: media.uri, name, mimeType } | |
| 72 | 38 | : { |
| 73 | 39 | reference, |
| 74 | 40 | environmentId: input.environmentId, |
| 75 | 41 | threadId: input.threadId, |
| 76 | resource: { _tag: "media-file", threadId: input.threadId, path }, | |
| 42 | resource: media.resource, | |
| 77 | 43 | name, |
| 78 | 44 | mimeType, |
| 79 | 45 | }; |
| 80 | 46 | return kind === "video" |
| 81 | ? { | |
| 82 | kind, | |
| 83 | source: { type: "media", name, mimeType, ...target, actionsSource }, | |
| 84 | } | |
| 85 | : { | |
| 86 | kind, | |
| 87 | source: { kind, name, ...target, actionsSource }, | |
| 88 | }; | |
| 47 | ? { kind, source: { type: "media", name, mimeType, ...target, actionsSource } } | |
| 48 | : { kind, source: { kind, name, ...target, actionsSource } }; | |
| 89 | 49 | } |
apps/mobile/src/lib/mediaActions.ts+73-46| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | |
| 2 | 3 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; |
| 3 | 4 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | 5 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; |
| ... | ... | @@ -7,18 +8,23 @@ import { Alert } from "react-native"; |
| 7 | 8 | |
| 8 | 9 | import { useRefreshAssetUrl } from "../state/assets"; |
| 9 | 10 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; |
| 11 | import type { DraftComposerFileAttachment } from "./composerImages"; | |
| 10 | 12 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; |
| 13 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | |
| 11 | 14 | |
| 12 | 15 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ |
| 13 | 16 | export type MediaActionsSource = { |
| 14 | 17 | readonly reference?: MediaReference; |
| 15 | 18 | readonly name: string; |
| 16 | 19 | readonly mimeType: string; |
| 20 | /** Anchors the iOS share sheet to the view that opened the menu. */ | |
| 21 | readonly sourceIdentifier?: string; | |
| 17 | 22 | } & ( |
| 18 | 23 | | { readonly uri: string } |
| 24 | | { readonly attachment: DraftComposerFileAttachment } | |
| 19 | 25 | | { |
| 20 | 26 | readonly environmentId: EnvironmentId; |
| 21 | readonly threadId: ThreadId; | |
| 27 | readonly threadId?: ThreadId; | |
| 22 | 28 | readonly resource: AssetResource; |
| 23 | 29 | } |
| 24 | 30 | ); |
| ... | ... | @@ -39,12 +45,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi |
| 39 | 45 | controller.current = request; |
| 40 | 46 | setSharing(true); |
| 41 | 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 | 58 | const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); |
| 43 | 59 | if (request.signal.aborted) return; |
| 44 | 60 | if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); |
| 45 | 61 | const input = { |
| 46 | 62 | attachment: { name: source.name, mimeType: source.mimeType }, |
| 47 | 63 | signal: request.signal, |
| 64 | sourceIdentifier: source.sourceIdentifier, | |
| 48 | 65 | }; |
| 49 | 66 | if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); |
| 50 | 67 | else await downloadAndShareAttachment({ ...input, url: uri }); |
| ... | ... | @@ -66,52 +83,62 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi |
| 66 | 83 | }; |
| 67 | 84 | |
| 68 | 85 | const reference = source?.reference; |
| 69 | const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source | |
| 70 | ? [ | |
| 71 | ...(reference?.kind === "file" | |
| 72 | ? [ | |
| 73 | { | |
| 74 | id: "copy-path", | |
| 75 | title: "Copy full path", | |
| 76 | run: () => copyTextWithHaptic(reference.path), | |
| 77 | }, | |
| 78 | ...(reference.relativePath | |
| 79 | ? [ | |
| 80 | { | |
| 81 | id: "copy-relative-path", | |
| 82 | title: "Copy relative path", | |
| 83 | run: () => copyTextWithHaptic(reference.relativePath!), | |
| 84 | }, | |
| 85 | ] | |
| 86 | : []), | |
| 87 | ...(reference.relativePath && source && "environmentId" in source | |
| 88 | ? [ | |
| 89 | { | |
| 90 | id: "open-file", | |
| 91 | title: "Open in file viewer", | |
| 92 | run: () => { | |
| 93 | onOpenFile?.(); | |
| 94 | navigation.navigate("ThreadFile", { | |
| 95 | environmentId: String(source.environmentId), | |
| 96 | threadId: String(source.threadId), | |
| 97 | path: reference.relativePath!.split("/"), | |
| 98 | }); | |
| 99 | }, | |
| 100 | }, | |
| 101 | ] | |
| 102 | : []), | |
| 103 | ] | |
| 104 | : reference | |
| 105 | ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] | |
| 86 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; | |
| 87 | const threadId = source && "threadId" in source ? source.threadId : undefined; | |
| 88 | const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = | |
| 89 | source | |
| 90 | ? [ | |
| 91 | ...(reference?.kind === "file" | |
| 92 | ? [ | |
| 93 | { | |
| 94 | id: "copy-full-path" as const, | |
| 95 | title: "Copy full path", | |
| 96 | run: () => copyTextWithHaptic(reference.path), | |
| 97 | }, | |
| 98 | ] | |
| 99 | : []), | |
| 100 | ...(relativePath | |
| 101 | ? [ | |
| 102 | { | |
| 103 | id: "copy-relative-path" as const, | |
| 104 | title: "Copy relative path", | |
| 105 | run: () => copyTextWithHaptic(relativePath), | |
| 106 | }, | |
| 107 | ] | |
| 108 | : []), | |
| 109 | ...(reference?.kind === "url" | |
| 110 | ? [ | |
| 111 | { | |
| 112 | id: "copy-url" as const, | |
| 113 | title: "Copy URL", | |
| 114 | run: () => copyTextWithHaptic(reference.url), | |
| 115 | }, | |
| 116 | ] | |
| 117 | : []), | |
| 118 | ...(relativePath && "environmentId" in source && threadId !== undefined | |
| 119 | ? [ | |
| 120 | { | |
| 121 | id: "open-file" as const, | |
| 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 | { | |
| 108 | id: "share", | |
| 109 | title: sharing ? "Opening share sheet…" : "Save or share", | |
| 110 | run: share, | |
| 111 | disabled: sharing, | |
| 112 | }, | |
| 113 | ] | |
| 114 | : []; | |
| 134 | { | |
| 135 | id: "save" as const, | |
| 136 | title: sharing ? "Opening share sheet…" : "Save or share", | |
| 137 | run: share, | |
| 138 | disabled: sharing, | |
| 139 | }, | |
| 140 | ] | |
| 141 | : []; | |
| 115 | 142 | return { |
| 116 | 143 | title: reference?.kind === "file" ? reference.path : reference?.url, |
| 117 | 144 | actions, |
apps/mobile/src/lib/videoPreviewSource.ts+46-17| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 2 | 3 | |
| 3 | 4 | import type { DraftComposerFileAttachment } from "./composerImages"; |
| 4 | 5 | import type { MediaActionsSource } from "./mediaActions"; |
| ... | ... | @@ -14,7 +15,7 @@ export type MediaVideoPreviewSource = { |
| 14 | 15 | | { readonly uri: string } |
| 15 | 16 | | { |
| 16 | 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 | 33 | return JSON.stringify( |
| 33 | 34 | "uri" in source |
| 34 | 35 | ? ["media-video", source.uri] |
| 35 | : [ | |
| 36 | "media-video", | |
| 37 | source.environmentId, | |
| 38 | source.resource.threadId, | |
| 39 | source.resource.path, | |
| 40 | source.srcFragment ?? "", | |
| 41 | ], | |
| 36 | : source.resource._tag === "attachment" | |
| 37 | ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] | |
| 38 | : [ | |
| 39 | "media-video", | |
| 40 | source.environmentId, | |
| 41 | source.resource.threadId, | |
| 42 | source.resource.path, | |
| 43 | source.srcFragment ?? "", | |
| 44 | ], | |
| 42 | 45 | ); |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | export type AttachmentVideoPreviewSource = ( | |
| 46 | | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } | |
| 47 | | { | |
| 48 | readonly type: "remote"; | |
| 49 | readonly environmentId: EnvironmentId; | |
| 50 | readonly attachment: ChatFileAttachment; | |
| 51 | } | |
| 52 | ) & { readonly sourceIdentifier?: string }; | |
| 48 | export interface LocalVideoPreviewSource { | |
| 49 | readonly type: "local"; | |
| 50 | readonly attachment: DraftComposerFileAttachment; | |
| 51 | readonly sourceIdentifier?: string; | |
| 52 | } | |
| 53 | ||
| 54 | export type VideoPreviewSource = LocalVideoPreviewSource | MediaVideoPreviewSource; | |
| 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 | 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 | 8 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 5 | 9 | import { useCallback } from "react"; |
| 6 | 10 | |
| 7 | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | 12 | import { usePreparedConnection } from "./session"; |
| 9 | 13 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 10 | 14 | |
| 11 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | |
| 12 | ||
| 13 | const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | |
| 14 | Atom.withLabel("mobile-asset-url:empty"), | |
| 15 | ); | |
| 15 | export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; | |
| 16 | 16 | |
| 17 | export type AssetUrlState = | |
| 18 | | { readonly _tag: "Loading" } | |
| 19 | | { readonly _tag: "Failure" } | |
| 20 | | { readonly _tag: "Success"; readonly url: string }; | |
| 17 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | |
| 21 | 18 | |
| 22 | 19 | export function useAssetUrlState( |
| 23 | 20 | environmentId: EnvironmentId | null, |
| ... | ... | @@ -29,14 +26,10 @@ export function useAssetUrlState( |
| 29 | 26 | ? EMPTY_ASSET_URL_ATOM |
| 30 | 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 31 | 28 | ); |
| 32 | if (result._tag === "Failure") { | |
| 33 | return { _tag: "Failure" }; | |
| 34 | } | |
| 35 | if (preparedConnection._tag === "None" || result._tag !== "Success") { | |
| 36 | return { _tag: "Loading" }; | |
| 37 | } | |
| 38 | const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | |
| 39 | return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; | |
| 29 | return assetUrlStateFromResult( | |
| 30 | result, | |
| 31 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | |
| 32 | ); | |
| 40 | 33 | } |
| 41 | 34 | |
| 42 | 35 | export function useAssetUrl( |
| ... | ... | @@ -60,9 +53,10 @@ export function useRefreshAssetUrl( |
| 60 | 53 | }); |
| 61 | 54 | return useCallback(async () => { |
| 62 | 55 | if (environmentId === null || resource === null || httpBaseUrl === null) return null; |
| 63 | const result = await createUrl({ environmentId, input: { resource } }); | |
| 64 | return result._tag === "Success" | |
| 65 | ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) | |
| 66 | : null; | |
| 56 | const state = assetUrlStateFromResult( | |
| 57 | await createUrl({ environmentId, input: { resource } }), | |
| 58 | httpBaseUrl, | |
| 59 | ); | |
| 60 | return state._tag === "Success" ? state.url : null; | |
| 67 | 61 | }, [createUrl, environmentId, httpBaseUrl, resource]); |
| 68 | 62 | } |
apps/web/src/assets/assetUrls.ts+24-35| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | 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 | 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | 9 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 5 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| ... | ... | @@ -9,58 +14,42 @@ import { assetEnvironment } from "~/state/assets"; |
| 9 | 14 | import { usePreparedConnection } from "~/state/session"; |
| 10 | 15 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 11 | 16 | |
| 12 | export { resolveAssetUrl } 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 }; | |
| 17 | export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; | |
| 18 | 18 | |
| 19 | 19 | export function useAssetUrlState( |
| 20 | environmentId: EnvironmentId, | |
| 21 | resource: AssetResource, | |
| 20 | environmentId: EnvironmentId | null, | |
| 21 | resource: AssetResource | null, | |
| 22 | 22 | ): AssetUrlState { |
| 23 | 23 | const preparedConnection = usePreparedConnection(environmentId); |
| 24 | 24 | const result = useAtomValue( |
| 25 | assetEnvironment.createUrl({ | |
| 26 | environmentId, | |
| 27 | input: { resource }, | |
| 28 | }), | |
| 25 | environmentId === null || resource === null | |
| 26 | ? EMPTY_ASSET_URL_ATOM | |
| 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | |
| 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 | 39 | const result = useAssetUrlState(environmentId, resource); |
| 48 | if (result._tag !== "Success") { | |
| 49 | return null; | |
| 50 | } | |
| 51 | return result.url; | |
| 40 | return result._tag === "Success" ? result.url : null; | |
| 52 | 41 | } |
| 53 | 42 | |
| 54 | /** Re-mints an exact-file capability after a file change or an explicit retry. */ | |
| 55 | 43 | export function useAssetUrlRefresh( |
| 56 | environmentId: EnvironmentId, | |
| 57 | resource: AssetResource, | |
| 44 | environmentId: EnvironmentId | null, | |
| 45 | resource: AssetResource | null, | |
| 58 | 46 | ): () => Promise<void> { |
| 59 | 47 | const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 60 | 48 | reportFailure: false, |
| 61 | 49 | refresh: true, |
| 62 | 50 | }); |
| 63 | 51 | return useCallback(async () => { |
| 52 | if (environmentId === null || resource === null) return; | |
| 64 | 53 | const result = await refresh({ environmentId, input: { resource } }); |
| 65 | 54 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 66 | 55 | }, [environmentId, resource, refresh]); |
apps/web/src/components/ChatMarkdown.tsx-24| ... | ... | @@ -1274,7 +1274,6 @@ function ChatMarkdownVideo(props: { |
| 1274 | 1274 | readonly mediaIdentity?: string | undefined; |
| 1275 | 1275 | readonly actionsSource?: MediaActionSource | undefined; |
| 1276 | 1276 | readonly onRetry?: (() => Promise<void>) | undefined; |
| 1277 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; | |
| 1278 | 1277 | }) { |
| 1279 | 1278 | return ( |
| 1280 | 1279 | <MediaVideoPlayer |
| ... | ... | @@ -1296,27 +1295,6 @@ function ChatMarkdownVideo(props: { |
| 1296 | 1295 | )} |
| 1297 | 1296 | onRetry={props.onRetry} |
| 1298 | 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 | 1356 | style={props.style} |
| 1379 | 1357 | mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1380 | 1358 | onRetry={refreshAssetUrl} |
| 1381 | onImageExpand={props.onImageExpand} | |
| 1382 | 1359 | actionsSource={actionsSource} |
| 1383 | 1360 | /> |
| 1384 | 1361 | ); |
| ... | ... | @@ -2642,7 +2619,6 @@ function ChatMarkdown({ |
| 2642 | 2619 | copyMarkdown={copyMarkdown} |
| 2643 | 2620 | originalUrl={originalUrl} |
| 2644 | 2621 | style={authoredSizeStyle} |
| 2645 | onImageExpand={imageExpand} | |
| 2646 | 2622 | actionsSource={actionsSource} |
| 2647 | 2623 | /> |
| 2648 | 2624 | ); |
apps/web/src/components/ChatView.logic.test.ts-9| ... | ... | @@ -24,7 +24,6 @@ import { |
| 24 | 24 | dismissBranchMismatchForSession, |
| 25 | 25 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, |
| 26 | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | isVideoPreviewRequestCurrent, | |
| 28 | 27 | hasEnvironmentReconnectWarningGraceElapsed, |
| 29 | 28 | hasServerAcknowledgedLocalDispatch, |
| 30 | 29 | isBranchMismatchDismissedForSession, |
| ... | ... | @@ -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 | 132 | describe("toolGroupConsumesUpwardNavigation", () => { |
| 142 | 133 | class ScrollElement extends EventTarget { |
| 143 | 134 | scrollTop = 0; |
apps/web/src/components/ChatView.logic.ts-9| ... | ... | @@ -399,15 +399,6 @@ export async function resolveFileAttachmentUrl(input: { |
| 399 | 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 | 402 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { |
| 412 | 403 | if (message.role !== "user" || !message.attachments) { |
| 413 | 404 | return; |
apps/web/src/components/ChatView.tsx+12-53| ... | ... | @@ -135,7 +135,6 @@ import { |
| 135 | 135 | type ChatMessage, |
| 136 | 136 | isBrowserPreviewAttachment, |
| 137 | 137 | isImageAttachment, |
| 138 | videoMimeType, | |
| 139 | 138 | type SessionPhase, |
| 140 | 139 | type Thread, |
| 141 | 140 | type TurnDiffSummary, |
| ... | ... | @@ -300,7 +299,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 300 | 299 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; |
| 301 | 300 | import { ChatHeader } from "./chat/ChatHeader"; |
| 302 | 301 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 303 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 302 | import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 304 | 303 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 305 | 304 | import { WorkspacePageHeader } from "./WorkspacePageHeader"; |
| 306 | 305 | import { |
| ... | ... | @@ -375,7 +374,6 @@ import { |
| 375 | 374 | deriveLockedProvider, |
| 376 | 375 | readFileAsDataUrl, |
| 377 | 376 | resolveFileAttachmentUrl, |
| 378 | isVideoPreviewRequestCurrent, | |
| 379 | 377 | reconcileMountedTerminalThreadIds, |
| 380 | 378 | resolveBackgroundDraftWorkspaceOptions, |
| 381 | 379 | resolveDraftHeroState, |
| ... | ... | @@ -1509,19 +1507,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 1509 | 1507 | [composerRef], |
| 1510 | 1508 | ); |
| 1511 | 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 | 1510 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 1520 | 1511 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 1521 | 1512 | useEffect(() => { |
| 1522 | 1513 | const item = expandedImage?.images[expandedImage.index]; |
| 1523 | if (item?.type !== "video" || !item.src.startsWith("blob:")) return; | |
| 1524 | return () => revokeBlobPreviewUrl(item.src); | |
| 1514 | if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; | |
| 1515 | const src = item.src; | |
| 1516 | return () => revokeBlobPreviewUrl(src); | |
| 1525 | 1517 | }, [expandedImage]); |
| 1526 | 1518 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 1527 | 1519 | const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< |
| ... | ... | @@ -2575,12 +2567,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 2575 | 2567 | useEffect(() => { |
| 2576 | 2568 | return () => { |
| 2577 | 2569 | clearAttachmentPreviewHandoffs(); |
| 2578 | cancelVideoPreviewRequest(); | |
| 2579 | 2570 | for (const message of optimisticUserMessagesRef.current) { |
| 2580 | 2571 | revokeUserMessagePreviewUrls(message); |
| 2581 | 2572 | } |
| 2582 | 2573 | }; |
| 2583 | }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); | |
| 2574 | }, [clearAttachmentPreviewHandoffs]); | |
| 2584 | 2575 | const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { |
| 2585 | 2576 | if (previewUrls.length === 0) return; |
| 2586 | 2577 | |
| ... | ... | @@ -2608,18 +2599,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2608 | 2599 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2609 | 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 | 2603 | try { |
| 2625 | 2604 | const url = await resolveFileAttachmentUrl({ |
| ... | ... | @@ -2628,30 +2607,19 @@ function ChatViewContent(props: ChatViewProps) { |
| 2628 | 2607 | httpBaseUrl: connection.httpBaseUrl, |
| 2629 | 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 | 2610 | const anchor = document.createElement("a"); |
| 2640 | 2611 | anchor.href = url; |
| 2641 | 2612 | anchor.download = attachment.name; |
| 2642 | 2613 | anchor.click(); |
| 2643 | 2614 | } catch (error) { |
| 2644 | if (!isCurrentRequest()) return; | |
| 2645 | 2615 | toastManager.add({ |
| 2646 | 2616 | type: "error", |
| 2647 | title: "Could not " + action + " " + attachment.name, | |
| 2617 | title: "Could not download " + attachment.name, | |
| 2648 | 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 | 2624 | const openFileAttachment = useCallback( |
| 2657 | 2625 | (attachment: ChatFileAttachment) => { |
| ... | ... | @@ -4690,10 +4658,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4690 | 4658 | return []; |
| 4691 | 4659 | }); |
| 4692 | 4660 | resetLocalDispatch(); |
| 4693 | cancelVideoPreviewRequest(); | |
| 4694 | setOpeningVideoAttachmentId(null); | |
| 4695 | 4661 | setExpandedImage(null); |
| 4696 | }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); | |
| 4662 | }, [draftId, resetLocalDispatch, threadId]); | |
| 4697 | 4663 | |
| 4698 | 4664 | const closeExpandedImage = useCallback(() => { |
| 4699 | 4665 | setExpandedImage(null); |
| ... | ... | @@ -7188,14 +7154,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 7188 | 7154 | } |
| 7189 | 7155 | }; |
| 7190 | 7156 | |
| 7191 | const onExpandTimelineImage = useCallback( | |
| 7192 | (preview: ExpandedImagePreview) => { | |
| 7193 | cancelVideoPreviewRequest(); | |
| 7194 | setOpeningVideoAttachmentId(null); | |
| 7195 | setExpandedImage(preview); | |
| 7196 | }, | |
| 7197 | [cancelVideoPreviewRequest], | |
| 7198 | ); | |
| 7157 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { | |
| 7158 | setExpandedImage(preview); | |
| 7159 | }, []); | |
| 7199 | 7160 | const onOpenTurnDiff = useCallback( |
| 7200 | 7161 | (turnId: TurnId, filePath?: string) => { |
| 7201 | 7162 | if (!isServerThread || !activeThreadRef) return; |
| ... | ... | @@ -7525,7 +7486,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 7525 | 7486 | onImageExpand={onExpandTimelineImage} |
| 7526 | 7487 | onFileOpen={openFileAttachment} |
| 7527 | 7488 | onFileDownload={downloadFileAttachment} |
| 7528 | openingVideoAttachmentId={openingVideoAttachmentId} | |
| 7529 | 7489 | markdownCwd={gitCwd ?? undefined} |
| 7530 | 7490 | resolvedTheme={resolvedTheme} |
| 7531 | 7491 | timestampFormat={timestampFormat} |
| ... | ... | @@ -7701,7 +7661,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 7701 | 7661 | setThreadError={setThreadError} |
| 7702 | 7662 | onExpandImage={onExpandTimelineImage} |
| 7703 | 7663 | onFileOpen={openFileAttachment} |
| 7704 | openingVideoAttachmentId={openingVideoAttachmentId} | |
| 7705 | 7664 | /> |
| 7706 | 7665 | </div> |
| 7707 | 7666 | </ComposerSurface.Host> |
| ... | ... | @@ -7922,7 +7881,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7922 | 7881 | |
| 7923 | 7882 | {expandedImage && ( |
| 7924 | 7883 | <ExpandedImageDialog |
| 7925 | key={`${expandedImage.images[expandedImage.index]?.src ?? "image"}:${expandedImage.index}`} | |
| 7884 | key={expandedImageKey(expandedImage)} | |
| 7926 | 7885 | preview={expandedImage} |
| 7927 | 7886 | onClose={closeExpandedImage} |
| 7928 | 7887 | /> |
apps/web/src/components/chat/ChatComposer.tsx+3-13| ... | ... | @@ -741,7 +741,6 @@ export interface ChatComposerProps { |
| 741 | 741 | setThreadError: (threadId: ThreadId | null, error: string | null) => void; |
| 742 | 742 | onExpandImage: (preview: ExpandedImagePreview) => void; |
| 743 | 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 | 819 | setThreadError, |
| 821 | 820 | onExpandImage, |
| 822 | 821 | onFileOpen, |
| 823 | openingVideoAttachmentId, | |
| 824 | 822 | } = props; |
| 825 | 823 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; |
| 826 | 824 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; |
| ... | ... | @@ -4020,7 +4018,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4020 | 4018 | maxFileAttachmentBytes !== null && |
| 4021 | 4019 | file.sizeBytes <= maxFileAttachmentBytes; |
| 4022 | 4020 | const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; |
| 4023 | const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId; | |
| 4024 | 4021 | return ( |
| 4025 | 4022 | <div |
| 4026 | 4023 | key={file.id} |
| ... | ... | @@ -4028,12 +4025,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4028 | 4025 | > |
| 4029 | 4026 | <button |
| 4030 | 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" | |
| 4032 | aria-busy={isOpening || undefined} | |
| 4033 | aria-disabled={isOpening || undefined} | |
| 4034 | aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`} | |
| 4028 | className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white" | |
| 4029 | aria-label={`Play ${file.name}`} | |
| 4035 | 4030 | onClick={() => { |
| 4036 | if (isOpening) return; | |
| 4037 | 4031 | if (file.file !== null) { |
| 4038 | 4032 | const preview = buildExpandedImagePreview([file], file.id); |
| 4039 | 4033 | if (preview) onExpandImage(preview); |
| ... | ... | @@ -4049,11 +4043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4049 | 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 ? ( | |
| 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 | )} | |
| 4046 | <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" /> | |
| 4057 | 4047 | </button> |
| 4058 | 4048 | {upload?.status === "uploading" && ( |
| 4059 | 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 | 2 | import { createPortal } from "react-dom"; |
| 3 | 3 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; |
| 4 | 4 | import { Button } from "../ui/button"; |
| 5 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 6 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 5 | import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 7 | 6 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 7 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | |
| 8 | 8 | import { OpenMediaLink } from "../media/OpenMediaLink"; |
| 9 | 9 | import { MediaActions, type MediaActionSource } from "../media/MediaActions"; |
| 10 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | |
| 10 | 11 | import { isContextMenuOpen } from "../../contextMenuFallback"; |
| 11 | 12 | |
| 12 | 13 | interface ExpandedImageDialogProps { |
| ... | ... | @@ -14,23 +15,47 @@ interface ExpandedImageDialogProps { |
| 14 | 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 | 21 | function ExpandedMediaFailure({ children }: { children: ReactNode }) { |
| 18 | 22 | return ( |
| 19 | <div | |
| 20 | role="alert" | |
| 21 | className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-sm text-white shadow-2xl" | |
| 22 | > | |
| 23 | <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}> | |
| 23 | 24 | {children} |
| 24 | 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 | 54 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 29 | 55 | preview, |
| 30 | 56 | onClose, |
| 31 | 57 | }: ExpandedImageDialogProps) { |
| 32 | 58 | const [imageOffset, setImageOffset] = useState(0); |
| 33 | const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null); | |
| 34 | 59 | const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); |
| 35 | 60 | const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; |
| 36 | 61 | const item = preview.images[index]; |
| ... | ... | @@ -125,24 +150,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 125 | 150 | > |
| 126 | 151 | <XIcon /> |
| 127 | 152 | </Button> |
| 128 | {item.type === "video" && failedVideoSrc === item.src ? ( | |
| 129 | <ExpandedMediaFailure> | |
| 130 | <p>This video could not be loaded or played.</p> | |
| 131 | <OpenMediaLink originalUrl={item.originalUrl} src={item.src} fileName={item.name} /> | |
| 132 | </ExpandedMediaFailure> | |
| 133 | ) : item.type === "video" ? ( | |
| 134 | <video | |
| 135 | src={item.src} | |
| 136 | aria-label={item.name} | |
| 137 | autoPlay={item.autoPlay ?? true} | |
| 138 | preload="metadata" | |
| 139 | controls | |
| 140 | playsInline | |
| 141 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} | |
| 142 | onError={() => setFailedVideoSrc(item.src)} | |
| 143 | className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl" | |
| 144 | /> | |
| 145 | ) : failedImageSrc === item.src ? ( | |
| 153 | {item.type === "video" ? ( | |
| 154 | <ExpandedVideo key={index} item={item} /> | |
| 155 | ) : item.src === null || failedImageSrc === item.src ? ( | |
| 146 | 156 | <ExpandedMediaFailure> |
| 147 | 157 | <p> |
| 148 | 158 | {openOriginalLink |
apps/web/src/components/chat/ExpandedImagePreview.tsx+45-45| ... | ... | @@ -3,31 +3,30 @@ import { type ChatImageAttachment, isVideoAttachment } from "../../types"; |
| 3 | 3 | import type { |
| 4 | 4 | AssetCreateUrlResult, |
| 5 | 5 | AssetResource, |
| 6 | ChatFileAttachment, | |
| 6 | 7 | EnvironmentId, |
| 7 | 8 | ScopedThreadRef, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | import { | |
| 10 | classifyMarkdownImageSource, | |
| 11 | markdownImageSourceFragment, | |
| 12 | } from "@t3tools/client-runtime/markdown-images"; | |
| 13 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | |
| 10 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 11 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | |
| 14 | 12 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 15 | 13 | import { |
| 16 | 14 | squashAtomCommandFailure, |
| 17 | 15 | type AtomCommandResult, |
| 18 | 16 | } from "@t3tools/client-runtime/state/runtime"; |
| 19 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | |
| 20 | 17 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 21 | 18 | import type { MediaActionSource } from "../media/MediaActions"; |
| 22 | 19 | import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; |
| 23 | 20 | |
| 24 | 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 | 24 | name: string; |
| 27 | 25 | type?: "video"; |
| 28 | 26 | autoPlay?: boolean; |
| 29 | 27 | /** Authored remote destination to open when embedding fails, never a generated asset URL. */ |
| 30 | 28 | originalUrl?: string; |
| 29 | srcFragment?: string; | |
| 31 | 30 | actionsSource?: MediaActionSource; |
| 32 | 31 | } |
| 33 | 32 | |
| ... | ... | @@ -49,45 +48,24 @@ export async function resolveMarkdownMediaPreview(input: { |
| 49 | 48 | input: { resource: AssetResource }; |
| 50 | 49 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>; |
| 51 | 50 | }): Promise<ExpandedImagePreview | null> { |
| 52 | const source = | |
| 53 | input.resolvedFilePath === undefined | |
| 54 | ? classifyMarkdownImageSource(input.source, input.cwd) | |
| 55 | : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath }; | |
| 56 | if (source._tag === "Blocked") return null; | |
| 57 | ||
| 58 | const path = | |
| 59 | source._tag === "Direct" | |
| 60 | ? source.uri.split(/[?#]/, 1)[0]! | |
| 61 | : source.path.replace(/:\d+(?::\d+)?$/, ""); | |
| 62 | const name = path.split(/[\\/]/).at(-1) ?? ""; | |
| 63 | const extensionIndex = name.lastIndexOf("."); | |
| 64 | const fileMimeType = | |
| 65 | extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex)); | |
| 66 | const kind = | |
| 67 | source._tag === "Direct" | |
| 68 | ? mediaKindFromPath(source.uri) | |
| 69 | : fileMimeType === null | |
| 70 | ? null | |
| 71 | : fileMimeType.startsWith("video/") | |
| 72 | ? "video" | |
| 73 | : "image"; | |
| 74 | if (kind === null) return null; | |
| 75 | ||
| 76 | const reference = | |
| 77 | source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd); | |
| 51 | const media = resolveMediaSource(input.source, { | |
| 52 | threadId: input.threadRef?.threadId, | |
| 53 | workspaceRoot: input.cwd, | |
| 54 | resolvedFilePath: input.resolvedFilePath, | |
| 55 | }); | |
| 56 | if (media === null) return null; | |
| 57 | const { kind, name, reference } = media; | |
| 78 | 58 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; |
| 59 | ||
| 79 | 60 | let src: string; |
| 80 | 61 | let asset: MediaActionSource["asset"]; |
| 81 | if (source._tag === "Direct") { | |
| 82 | src = resolveProtocolRelativeMediaUrl(source.uri); | |
| 62 | if (media.access === "direct") { | |
| 63 | src = resolveProtocolRelativeMediaUrl(media.uri); | |
| 83 | 64 | } else { |
| 84 | if (!input.threadRef || !input.httpBaseUrl) { | |
| 65 | if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) { | |
| 85 | 66 | throw new Error("Reconnect to this environment and open the media again."); |
| 86 | 67 | } |
| 87 | asset = { | |
| 88 | environmentId: input.threadRef.environmentId, | |
| 89 | resource: { _tag: "media-file", threadId: input.threadRef.threadId, path }, | |
| 90 | }; | |
| 68 | asset = { environmentId: input.threadRef.environmentId, resource: media.resource }; | |
| 91 | 69 | const result = await input.createAssetUrl({ |
| 92 | 70 | environmentId: asset.environmentId, |
| 93 | 71 | input: { resource: asset.resource }, |
| ... | ... | @@ -95,20 +73,21 @@ export async function resolveMarkdownMediaPreview(input: { |
| 95 | 73 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 96 | 74 | const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); |
| 97 | 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 | 78 | return { |
| 101 | 79 | images: [ |
| 102 | 80 | { |
| 103 | 81 | src, |
| 104 | name: name || kind, | |
| 82 | name, | |
| 105 | 83 | ...(kind === "video" ? { type: "video", autoPlay: false } : {}), |
| 106 | ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null | |
| 107 | ? { originalUrl: source.uri } | |
| 84 | ...(media.access === "direct" && resolveExternalWebLinkHost(media.uri) !== null | |
| 85 | ? { originalUrl: media.uri } | |
| 108 | 86 | : {}), |
| 87 | ...(media.srcFragment ? { srcFragment: media.srcFragment } : {}), | |
| 109 | 88 | actionsSource: { |
| 110 | 89 | kind, |
| 111 | name: name || kind, | |
| 90 | name, | |
| 112 | 91 | src, |
| 113 | 92 | ...(reference ? { reference } : {}), |
| 114 | 93 | ...(asset ? { asset } : {}), |
| ... | ... | @@ -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 | 125 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { |
| 126 | 126 | const url = URL.createObjectURL(file); |
| 127 | 127 | video.src = url; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+31-16| ... | ... | @@ -147,7 +147,6 @@ function buildProps() { |
| 147 | 147 | revertTurnCountByUserMessageId: new Map(), |
| 148 | 148 | onRevertUserMessage: () => {}, |
| 149 | 149 | isRevertingCheckpoint: false, |
| 150 | openingVideoAttachmentId: null, | |
| 151 | 150 | onImageExpand: () => {}, |
| 152 | 151 | activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, |
| 153 | 152 | markdownCwd: undefined, |
| ... | ... | @@ -465,7 +464,7 @@ describe("MessagesTimeline", () => { |
| 465 | 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 | 468 | const entry = { |
| 470 | 469 | ...buildUserTimelineEntry("Watch the demo."), |
| 471 | 470 | message: { |
| ... | ... | @@ -477,6 +476,7 @@ describe("MessagesTimeline", () => { |
| 477 | 476 | name: "demo.mp4", |
| 478 | 477 | mimeType: "video/mp4", |
| 479 | 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 | 485 | const markup = renderToStaticMarkup( |
| 486 | 486 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, |
| 487 | 487 | ); |
| 488 | const busyMarkup = renderToStaticMarkup( | |
| 489 | <MessagesTimeline | |
| 490 | {...buildProps()} | |
| 491 | timelineEntries={[entry]} | |
| 492 | openingVideoAttachmentId="attachment-demo-mp4" | |
| 493 | />, | |
| 488 | ||
| 489 | expect(markup).toContain("<video"); | |
| 490 | expect(markup).toContain('aria-label="demo.mp4"'); | |
| 491 | expect(markup).toContain('controls=""'); | |
| 492 | expect(markup).not.toContain("Expand demo.mp4"); | |
| 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"'); | |
| 497 | expect(markup).toContain("min-h-[72px]"); | |
| 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>"); | |
| 517 | expect(markup).not.toContain("<video"); | |
| 518 | expect(markup).toContain(">pending-demo.mp4</div>"); | |
| 504 | 519 | }); |
| 505 | 520 | it("renders an ordinary file download button without creating its URL in advance", () => { |
| 506 | 521 | const entry = { |
apps/web/src/components/chat/MessagesTimeline.tsx+53-40| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { |
| 2 | 2 | type AssistantCitation, |
| 3 | type ChatFileAttachment, | |
| 4 | 3 | type EnvironmentId, |
| 5 | 4 | type MessageId, |
| 6 | 5 | type ScopedThreadRef, |
| ... | ... | @@ -50,6 +49,7 @@ import { |
| 50 | 49 | workLogEntryIsToolLike, |
| 51 | 50 | } from "../../session-logic"; |
| 52 | 51 | import { |
| 52 | type ChatFileAttachment, | |
| 53 | 53 | type ChatImageAttachment, |
| 54 | 54 | isBrowserPreviewAttachment, |
| 55 | 55 | isFileAttachment, |
| ... | ... | @@ -79,7 +79,6 @@ import { |
| 79 | 79 | MessageCircleIcon, |
| 80 | 80 | MousePointerClickIcon, |
| 81 | 81 | PaintbrushIcon, |
| 82 | PlayIcon, | |
| 83 | 82 | SearchIcon, |
| 84 | 83 | SquarePenIcon, |
| 85 | 84 | TerminalIcon, |
| ... | ... | @@ -89,8 +88,14 @@ import { |
| 89 | 88 | ZapIcon, |
| 90 | 89 | } from "lucide-react"; |
| 91 | 90 | import { Button } from "../ui/button"; |
| 91 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | |
| 92 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | |
| 92 | 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 | 99 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 95 | 100 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 96 | 101 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| ... | ... | @@ -188,7 +193,6 @@ interface TimelineRowSharedState { |
| 188 | 193 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 189 | 194 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 190 | 195 | onFileDownload: (attachment: ChatFileAttachment) => void; |
| 191 | openingVideoAttachmentId: string | null; | |
| 192 | 196 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 193 | 197 | onToggleTurnFold: (turnId: TurnId) => void; |
| 194 | 198 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| ... | ... | @@ -289,7 +293,6 @@ interface MessagesTimelineProps { |
| 289 | 293 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 290 | 294 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| 291 | 295 | onFileDownload?: (attachment: ChatFileAttachment) => void; |
| 292 | openingVideoAttachmentId: string | null; | |
| 293 | 296 | activeThreadEnvironmentId: EnvironmentId; |
| 294 | 297 | markdownCwd: string | undefined; |
| 295 | 298 | resolvedTheme: "light" | "dark"; |
| ... | ... | @@ -341,7 +344,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 341 | 344 | onImageExpand, |
| 342 | 345 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| 343 | 346 | onFileDownload = NOOP_OPEN_ATTACHMENT, |
| 344 | openingVideoAttachmentId, | |
| 345 | 347 | activeThreadEnvironmentId, |
| 346 | 348 | markdownCwd, |
| 347 | 349 | resolvedTheme, |
| ... | ... | @@ -638,7 +640,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 638 | 640 | onImageExpand, |
| 639 | 641 | onFileOpen, |
| 640 | 642 | onFileDownload, |
| 641 | openingVideoAttachmentId, | |
| 642 | 643 | onOpenTurnDiff, |
| 643 | 644 | onToggleTurnFold, |
| 644 | 645 | onToggleWorkGroup, |
| ... | ... | @@ -663,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 663 | 664 | onImageExpand, |
| 664 | 665 | onFileOpen, |
| 665 | 666 | onFileDownload, |
| 666 | openingVideoAttachmentId, | |
| 667 | 667 | onOpenTurnDiff, |
| 668 | 668 | onToggleTurnFold, |
| 669 | 669 | onToggleWorkGroup, |
| ... | ... | @@ -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 | 1169 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 1131 | 1170 | const ctx = use(TimelineRowCtx); |
| 1132 | 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 | 1204 | {regularImages.map((image) => ( |
| 1166 | 1205 | <div |
| 1167 | 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 | 1209 | {image.previewUrl ? ( |
| 1171 | 1210 | <button |
| 1172 | 1211 | type="button" |
| 1173 | className="h-full w-full cursor-zoom-in" | |
| 1212 | className="block h-full w-full cursor-zoom-in" | |
| 1174 | 1213 | aria-label={`Preview ${image.name}`} |
| 1175 | 1214 | onClick={() => { |
| 1176 | 1215 | const preview = buildExpandedImagePreview(regularImages, image.id); |
| ... | ... | @@ -1181,7 +1220,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1181 | 1220 | <img |
| 1182 | 1221 | src={image.previewUrl} |
| 1183 | 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 | 1225 | </button> |
| 1187 | 1226 | ) : ( |
| ... | ... | @@ -1191,35 +1230,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1191 | 1230 | )} |
| 1192 | 1231 | </div> |
| 1193 | 1232 | ))} |
| 1194 | {userVideos.map((file) => { | |
| 1195 | const isOpening = ctx.openingVideoAttachmentId === file.id; | |
| 1196 | return ( | |
| 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 | })} | |
| 1233 | {userVideos.map((file) => ( | |
| 1234 | <UserVideoAttachment key={file.id} file={file} /> | |
| 1235 | ))} | |
| 1223 | 1236 | </div> |
| 1224 | 1237 | )} |
| 1225 | 1238 | {previewAnnotations.map((annotation, index) => ( |
apps/web/src/components/media/MediaActions.tsx+21-16| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | |
| 1 | 2 | import { |
| 2 | 3 | mediaReferenceFileName, |
| 3 | 4 | type MediaReference, |
| ... | ... | @@ -68,8 +69,6 @@ export function useMediaActions(source: MediaActionSource) { |
| 68 | 69 | return { save, copyImage }; |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; | |
| 72 | ||
| 73 | 72 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ |
| 74 | 73 | export function MediaActions({ |
| 75 | 74 | source, |
| ... | ... | @@ -82,8 +81,6 @@ export function MediaActions({ |
| 82 | 81 | const [tooltipOpen, setTooltipOpen] = useState(false); |
| 83 | 82 | const menuOpen = useRef(false); |
| 84 | 83 | const reference = source.reference; |
| 85 | const hasActions = | |
| 86 | source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; | |
| 87 | 84 | const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); |
| 88 | 85 | |
| 89 | 86 | const showMenu = async (position: { x: number; y: number }) => { |
| ... | ... | @@ -94,28 +91,37 @@ export function MediaActions({ |
| 94 | 91 | let failureTitle = "Could not open media menu"; |
| 95 | 92 | let progressToast: ReturnType<typeof toastManager.add> | undefined; |
| 96 | 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 | 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 | 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 | 105 | } else if (reference?.kind === "url") { |
| 103 | 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 | 110 | if (source.kind === "image") { |
| 106 | const unavailable = source.src === null && source.asset === undefined; | |
| 107 | items.push({ id: "save", label: "Save image", disabled: unavailable }); | |
| 108 | items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); | |
| 111 | items.push({ | |
| 112 | id: "copy-image", | |
| 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 | 118 | const action = await api.contextMenu.show(items, position); |
| 113 | 119 | if (!action) return; |
| 114 | 120 | failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; |
| 115 | 121 | const text = |
| 116 | action === "copy-full" && reference?.kind === "file" | |
| 122 | action === "copy-full-path" && reference?.kind === "file" | |
| 117 | 123 | ? reference.path |
| 118 | : action === "copy-relative" && reference?.kind === "file" | |
| 124 | : action === "copy-relative-path" && reference?.kind === "file" | |
| 119 | 125 | ? reference.relativePath |
| 120 | 126 | : action === "copy-url" && reference?.kind === "url" |
| 121 | 127 | ? reference.url |
| ... | ... | @@ -131,7 +137,7 @@ export function MediaActions({ |
| 131 | 137 | } else if (action === "save" || action === "copy-image") { |
| 132 | 138 | progressToast = toastManager.add({ |
| 133 | 139 | type: "loading", |
| 134 | title: action === "save" ? "Preparing image download…" : "Copying image…", | |
| 140 | title: action === "save" ? `Preparing ${noun} download…` : "Copying image…", | |
| 135 | 141 | }); |
| 136 | 142 | await (action === "save" ? save() : copyImage()); |
| 137 | 143 | toastManager.update(progressToast, { |
| ... | ... | @@ -158,7 +164,7 @@ export function MediaActions({ |
| 158 | 164 | render={children} |
| 159 | 165 | tabIndex={0} |
| 160 | 166 | onContextMenu={(event) => { |
| 161 | if (!hasActions || event.defaultPrevented) return; | |
| 167 | if (event.defaultPrevented) return; | |
| 162 | 168 | event.preventDefault(); |
| 163 | 169 | event.stopPropagation(); |
| 164 | 170 | const bounds = event.currentTarget.getBoundingClientRect(); |
| ... | ... | @@ -170,7 +176,6 @@ export function MediaActions({ |
| 170 | 176 | }} |
| 171 | 177 | onKeyDown={(event) => { |
| 172 | 178 | if ( |
| 173 | !hasActions || | |
| 174 | 179 | event.defaultPrevented || |
| 175 | 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 | import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | |
| 1 | import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -14,11 +14,13 @@ interface MediaVideoPlayerProps { |
| 14 | 14 | readonly originalUrl?: string | undefined; |
| 15 | 15 | readonly revision?: string | null | undefined; |
| 16 | 16 | readonly preload?: "visible" | "metadata" | undefined; |
| 17 | readonly autoPlay?: boolean | undefined; | |
| 17 | 18 | readonly className?: string | undefined; |
| 18 | 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 | 22 | readonly style?: CSSProperties | undefined; |
| 20 | 23 | readonly copyMarkdown?: string | undefined; |
| 21 | readonly onExpand?: ((src: string) => void) | undefined; | |
| 22 | 24 | readonly onRetry?: (() => Promise<void>) | undefined; |
| 23 | 25 | readonly actionsSource?: MediaActionSource | undefined; |
| 24 | 26 | } |
| ... | ... | @@ -31,11 +33,12 @@ export function MediaVideoPlayer({ |
| 31 | 33 | originalUrl, |
| 32 | 34 | revision = null, |
| 33 | 35 | preload = "visible", |
| 36 | autoPlay = false, | |
| 34 | 37 | className, |
| 35 | 38 | videoClassName, |
| 39 | stateClassName, | |
| 36 | 40 | style, |
| 37 | 41 | copyMarkdown, |
| 38 | onExpand, | |
| 39 | 42 | onRetry, |
| 40 | 43 | actionsSource, |
| 41 | 44 | }: MediaVideoPlayerProps) { |
| ... | ... | @@ -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 | 120 | const player = ( |
| 135 | 121 | <span |
| 136 | 122 | className={cn("relative inline-block align-middle", className)} |
| ... | ... | @@ -140,7 +126,10 @@ export function MediaVideoPlayer({ |
| 140 | 126 | {failed ? ( |
| 141 | 127 | <span |
| 142 | 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 | 134 | <span className="inline-flex items-center gap-1.5"> |
| 146 | 135 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> |
| ... | ... | @@ -159,7 +148,6 @@ export function MediaVideoPlayer({ |
| 159 | 148 | </Button> |
| 160 | 149 | ) : null} |
| 161 | 150 | <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} /> |
| 162 | {expandButton} | |
| 163 | 151 | </span> |
| 164 | 152 | </span> |
| 165 | 153 | ) : src !== null ? ( |
| ... | ... | @@ -168,6 +156,7 @@ export function MediaVideoPlayer({ |
| 168 | 156 | ref={videoRef} |
| 169 | 157 | src={src} |
| 170 | 158 | aria-label={label || "Video preview"} |
| 159 | autoPlay={autoPlay} | |
| 171 | 160 | controls |
| 172 | 161 | playsInline |
| 173 | 162 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} |
| ... | ... | @@ -186,11 +175,10 @@ export function MediaVideoPlayer({ |
| 186 | 175 | <span |
| 187 | 176 | role="status" |
| 188 | 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 | 179 | style={style} |
| 191 | 180 | /> |
| 192 | 181 | )} |
| 193 | {!failed && expandButton} | |
| 194 | 182 | </span> |
| 195 | 183 | ); |
| 196 | 184 | return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player; |
apps/web/src/filePathDisplay.ts+11-16| ... | ... | @@ -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 | 8 | function normalizePathSeparators(path: string): string { |
| 4 | 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 | 12 | function trimTrailingPathSeparators(path: string): string { |
| 12 | 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 | 16 | function stripRelativePrefixes(path: string): string { |
| 21 | 17 | return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); |
| 22 | 18 | } |
| ... | ... | @@ -25,15 +21,15 @@ export function formatWorkspaceRelativePath( |
| 25 | 21 | pathWithPosition: string, |
| 26 | 22 | workspaceRoot: string | undefined, |
| 27 | 23 | ): string { |
| 28 | const { path, line, column } = splitPathAndPosition(pathWithPosition); | |
| 29 | const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); | |
| 24 | const position = splitFilePathPosition(pathWithPosition); | |
| 25 | const normalizedPath = stripSlashPrefixedWindowsDrive(normalizePathSeparators(position.path)); | |
| 30 | 26 | |
| 31 | 27 | let displayPath = normalizedPath; |
| 32 | 28 | if (workspaceRoot) { |
| 33 | const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( | |
| 29 | const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( | |
| 34 | 30 | normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), |
| 35 | 31 | ); |
| 36 | const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); | |
| 32 | const workspaceLabel = fileBasename(normalizedWorkspaceRoot); | |
| 37 | 33 | const pathForCompare = normalizedPath.toLowerCase(); |
| 38 | 34 | const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); |
| 39 | 35 | const workspaceWithSeparator = `${workspaceForCompare}/`; |
| ... | ... | @@ -52,6 +48,5 @@ export function formatWorkspaceRelativePath( |
| 52 | 48 | } |
| 53 | 49 | } |
| 54 | 50 | |
| 55 | if (!line) return displayPath; | |
| 56 | return `${displayPath}:${line}${column ? `:${column}` : ""}`; | |
| 51 | return formatFilePathPosition({ ...position, path: displayPath }); | |
| 57 | 52 | } |
apps/web/src/markdown-links.test.ts+8-1| ... | ... | @@ -270,8 +270,15 @@ describe("resolveMarkdownFileLinkTarget", () => { |
| 270 | 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 | 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 | 1 | import { |
| 2 | fileBasename, | |
| 3 | formatFilePathPosition, | |
| 2 | 4 | inlineCodeFilePathCandidate, |
| 3 | isConventionalFilePosition, | |
| 5 | isRelativeFilePath, | |
| 6 | normalizeMarkdownLinkDestination, | |
| 7 | parseFileUrlHref, | |
| 8 | parseMarkdownFileLink, | |
| 9 | safeDecodeURIComponent, | |
| 10 | splitFilePathPosition, | |
| 11 | workspaceRelativeFilePath, | |
| 4 | 12 | } from "@t3tools/client-runtime/markdown-links"; |
| 5 | 13 | |
| 6 | 14 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 7 | import { | |
| 8 | isTerminalLinkActivation, | |
| 9 | resolvePathLinkTarget, | |
| 10 | splitPathAndPosition, | |
| 11 | } from "./terminal-links"; | |
| 15 | import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; | |
| 16 | ||
| 17 | export { normalizeMarkdownLinkDestination }; | |
| 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 | 19 | const MARKDOWN_LINK_HREF_PATTERN = |
| 52 | 20 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; |
| 53 | 21 | |
| ... | ... | @@ -81,112 +49,14 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool |
| 81 | 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 | 52 | export function isWindowsDrivePathHref(href: string): boolean { |
| 93 | return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(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 | } | |
| 53 | return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); | |
| 141 | 54 | } |
| 142 | 55 | |
| 143 | 56 | export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { |
| 144 | 57 | if (!href) return null; |
| 145 | const normalizedHref = normalizeMarkdownLinkDestination(href); | |
| 146 | const target = parseFileUrlHref(normalizedHref, { decodePath: false }); | |
| 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); | |
| 58 | const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); | |
| 59 | return target ? `${target.path}${target.hash}` : null; | |
| 190 | 60 | } |
| 191 | 61 | |
| 192 | 62 | /** |
| ... | ... | @@ -200,34 +70,11 @@ export function resolveMarkdownFileLinkTarget( |
| 200 | 70 | baseDir: string | undefined = cwd, |
| 201 | 71 | ): string | null { |
| 202 | 72 | if (!href) return null; |
| 203 | const rawHref = normalizeMarkdownLinkDestination(href); | |
| 204 | if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) 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 | } | |
| 73 | const target = parseMarkdownFileLink(href); | |
| 74 | if (!target) return null; | |
| 230 | 75 | |
| 76 | const pathWithPosition = formatFilePathPosition(target); | |
| 77 | if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; | |
| 231 | 78 | if (!baseDir) return null; |
| 232 | 79 | return resolvePathLinkTarget(pathWithPosition, baseDir); |
| 233 | 80 | } |
| ... | ... | @@ -246,38 +93,7 @@ export function resolveInlineCodeFileLinkMeta( |
| 246 | 93 | const candidate = inlineCodeFilePathCandidate(codeText); |
| 247 | 94 | if (candidate === null) return null; |
| 248 | 95 | |
| 249 | const resolved = 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); | |
| 96 | return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); | |
| 281 | 97 | } |
| 282 | 98 | |
| 283 | 99 | export function resolveMarkdownFileLinkMeta( |
| ... | ... | @@ -291,19 +107,14 @@ export function resolveMarkdownFileLinkMeta( |
| 291 | 107 | } |
| 292 | 108 | |
| 293 | 109 | function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { |
| 294 | const { path, line, column } = splitPathAndPosition(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 | ||
| 110 | const { path, line, column } = splitFilePathPosition(targetPath); | |
| 300 | 111 | return { |
| 301 | 112 | filePath: path, |
| 302 | 113 | targetPath, |
| 303 | 114 | displayPath: formatWorkspaceRelativePath(targetPath, cwd), |
| 304 | workspaceRelativePath: workspaceRelativePath(path, cwd), | |
| 305 | basename: basenameOfPath(path), | |
| 306 | ...(lineNumber !== undefined ? { line: lineNumber } : {}), | |
| 307 | ...(columnNumber !== undefined ? { column: columnNumber } : {}), | |
| 115 | workspaceRelativePath: workspaceRelativeFilePath(path, cwd), | |
| 116 | basename: fileBasename(path), | |
| 117 | ...(line !== undefined ? { line } : {}), | |
| 118 | ...(column !== undefined ? { column } : {}), | |
| 308 | 119 | }; |
| 309 | 120 | } |
apps/web/src/terminal-links.ts+8-32| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | formatFilePathPosition, | |
| 3 | splitFilePathPosition, | |
| 4 | } from "@t3tools/client-runtime/markdown-links"; | |
| 5 | ||
| 1 | 6 | import { isMacPlatform } from "./lib/utils"; |
| 2 | 7 | |
| 3 | 8 | export type TerminalLinkKind = "url" | "path"; |
| ... | ... | @@ -137,35 +142,6 @@ function inferHomeFromCwd(cwd: string): string | undefined { |
| 137 | 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 | 145 | export function extractTerminalLinks(line: string): TerminalLinkMatch[] { |
| 170 | 146 | const urlMatches = collectMatches(line, "url", URL_PATTERN, []); |
| 171 | 147 | const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); |
| ... | ... | @@ -271,7 +247,8 @@ export function isTerminalLinkActivation( |
| 271 | 247 | } |
| 272 | 248 | |
| 273 | 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 | 253 | let resolvedPath = path; |
| 277 | 254 | if (path.startsWith("~/")) { |
| ... | ... | @@ -285,6 +262,5 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 285 | 262 | resolvedPath = joinPath(cwd, path, separator); |
| 286 | 263 | } |
| 287 | 264 | |
| 288 | if (!line) return resolvedPath; | |
| 289 | return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; | |
| 265 | return formatFilePathPosition({ ...position, path: resolvedPath }); | |
| 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 | 86 | Open PDF action for the native viewer. Android retains its image viewer and uses the |
| 87 | 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; | |
| 90 | the client does not download the entire file or show a separate opening overlay before | |
| 91 | presentation. The URL is captured once per preview so credential refresh does not | |
| 92 | restart playback. Saving or sharing still downloads the original file. | |
| 89 | Every full-screen video preview on iOS is AVKit, playing directly from the signed asset | |
| 90 | URL: sent attachments, composer drafts, and file links that point at a video. Inline | |
| 91 | markdown embeds and the workspace file screen play in place with the Expo Video player. | |
| 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 | 99 | The native presentation promise completes after dismissal. Local draft previews |
| 95 | 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 | 102 | AVPlayer activates audio as playback starts. The presenter pauses and releases |
| 98 | 103 | its own player on close, then restores the previous audio-session configuration |
| 99 | 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 | |
| 101 | its React Native modal and Expo Video player. | |
| 105 | shared session, which may still serve another player or recorder. Android plays every | |
| 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 | 110 | `shareFileFromSource` uses the same source registration to anchor UIKit's activity |
| 104 | 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 | 37 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 38 | 38 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. |
| 39 | 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 | |
| 41 | depend on the browser or device; you can save an unsupported video to open it in another app. | |
| 40 | Received videos stream from their environment as they play on every platform. Supported formats and | |
| 41 | codecs depend on the browser or device; you can save an unsupported video to open it in another app. | |
| 42 | 42 | |
| 43 | 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 | |
| 45 | the system share options. On Android, use **Save or share video** inside the preview. | |
| 44 | conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open | |
| 45 | the system share options. On Android, the same menu is also available inside the preview. | |
| 46 | 46 | |
| 47 | 47 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name |
| 48 | 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 | 97 | Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They |
| 98 | 98 | stream from the environment as needed, rather than downloading the entire video before playback. |
| 99 | 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 | 101 | an initial frame when supported, but stay paused until you press Play. Video file references use |
| 102 | 102 | a filmstrip icon. |
| 103 | 103 | |
| 104 | 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 | |
| 106 | built-in controls to download videos. If the player cannot decode a video, its error message | |
| 105 | to copy that reference, save the image or video, or copy an image to the clipboard. The video | |
| 106 | player's built-in controls can download a video too. If the player cannot decode a video, its error message | |
| 107 | 107 | offers a link to open the source in the browser. Workspace media also offers **Copy relative |
| 108 | 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 | |
| 111 | source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file | |
| 112 | viewer from the same menu. Saving downloads a copy only when you request it; it does not change | |
| 113 | how the video buffers during playback. On iOS, touch and hold a file reference in a message to | |
| 110 | On mobile, touch and hold an inline image or a video thumbnail to see its source, | |
| 111 | copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer | |
| 112 | from the same menu. Saving downloads a copy only when you request it; it does not change how | |
| 113 | the video buffers during playback. On iOS, touch and hold a file reference in a message to | |
| 114 | 114 | copy its full or relative path or open it in the file viewer. |
| 115 | 115 | |
| 116 | 116 | Use Markdown image syntax to embed either kind of media: |
packages/client-runtime/package.json+8| ... | ... | @@ -27,6 +27,14 @@ |
| 27 | 27 | "types": "./src/mediaReference.ts", |
| 28 | 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 | 38 | "./codex-file-citations": { |
| 31 | 39 | "types": "./src/codexFileCitations.ts", |
| 32 | 40 | "default": "./src/codexFileCitations.ts" |
packages/client-runtime/src/markdownImages.ts+24-56| ... | ... | @@ -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 | 11 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; |
| 2 | 12 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; |
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 4 | 13 | |
| 5 | 14 | export type MarkdownImageSource = |
| 6 | 15 | | { readonly _tag: "Direct"; readonly uri: string } |
| 7 | 16 | | { readonly _tag: "WorkspaceFile"; readonly path: string } |
| 8 | 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 | 19 | export function markdownImageSourceFragment(source: string): string { |
| 24 | const normalizedSource = normalizeSource(source); | |
| 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); | |
| 20 | return splitMarkdownLinkSearchAndHash(normalizeMarkdownLinkDestination(source)).hash; | |
| 58 | 21 | } |
| 59 | 22 | |
| 60 | 23 | function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { |
| 61 | const windows = | |
| 62 | WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); | |
| 63 | const separator = windows ? "\\" : "/"; | |
| 24 | const separator = isWindowsAbsolutePath(workspaceRoot) ? "\\" : "/"; | |
| 64 | 25 | const root = workspaceRoot.replace(/[\\/]+$/, ""); |
| 65 | 26 | const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); |
| 66 | 27 | return `${root}${separator}${path}`; |
| ... | ... | @@ -77,7 +38,7 @@ export function classifyMarkdownImageSource( |
| 77 | 38 | ): MarkdownImageSource { |
| 78 | 39 | if (value === null || value === undefined) return { _tag: "Blocked" }; |
| 79 | 40 | |
| 80 | const source = normalizeSource(value); | |
| 41 | const source = normalizeMarkdownLinkDestination(value); | |
| 81 | 42 | if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { |
| 82 | 43 | return { _tag: "Blocked" }; |
| 83 | 44 | } |
| ... | ... | @@ -86,13 +47,20 @@ export function classifyMarkdownImageSource( |
| 86 | 47 | } |
| 87 | 48 | |
| 88 | 49 | if (/^file:/i.test(source)) { |
| 89 | const path = parseFileUrl(source); | |
| 90 | return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; | |
| 50 | const target = parseFileUrlHref(source); | |
| 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 | 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 | 64 | return { _tag: "WorkspaceFile", path }; |
| 97 | 65 | } |
| 98 | 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 | 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 | 13 | describe("inlineCodeFilePathCandidate", () => { |
| 6 | 14 | it.each([ |
| ... | ... | @@ -28,3 +36,135 @@ describe("isConventionalFilePosition", () => { |
| 28 | 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 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 2 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 2 | ||
| 3 | const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; | |
| 3 | 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 | 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 | 14 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; |
| 6 | 15 | const PATH_SEPARATOR_PATTERN = /[\\/]/; |
| 7 | 16 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; |
| 8 | 17 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; |
| 9 | 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 | 47 | // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. |
| 11 | 48 | const EXTENSIONLESS_FILE_NAMES = new Set([ |
| 12 | 49 | "Makefile", |
| ... | ... | @@ -131,18 +168,14 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { |
| 131 | 168 | const trimmed = codeText.trim(); |
| 132 | 169 | if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; |
| 133 | 170 | |
| 134 | const candidate = | |
| 135 | WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) | |
| 136 | ? trimmed | |
| 137 | : trimmed.replaceAll("\\", "/"); | |
| 171 | const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/"); | |
| 138 | 172 | const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); |
| 139 | 173 | if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; |
| 140 | 174 | |
| 141 | 175 | const hasExplicitPathShape = |
| 142 | 176 | RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || |
| 143 | 177 | candidate.startsWith("/") || |
| 144 | WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || | |
| 145 | WINDOWS_UNC_PATH_PATTERN.test(candidate); | |
| 178 | isWindowsAbsolutePath(candidate); | |
| 146 | 179 | if (!hasExplicitPathShape) { |
| 147 | 180 | const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); |
| 148 | 181 | const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; |
| ... | ... | @@ -156,3 +189,159 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { |
| 156 | 189 | } |
| 157 | 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 | 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | 2 | |
| 3 | import { safeDecodeURIComponent } from "./markdownLinks.ts"; | |
| 4 | ||
| 3 | 5 | /** The authored media location, never the temporary URL used to load its bytes. */ |
| 4 | 6 | export type MediaReference = |
| 5 | 7 | | { |
| ... | ... | @@ -86,10 +88,5 @@ export function mediaReferenceFileName(reference: MediaReference): string | unde |
| 86 | 88 | } catch { |
| 87 | 89 | return undefined; |
| 88 | 90 | } |
| 89 | if (!basename) return undefined; | |
| 90 | try { | |
| 91 | return decodeURIComponent(basename); | |
| 92 | } catch { | |
| 93 | return basename; | |
| 94 | } | |
| 91 | return basename ? safeDecodeURIComponent(basename) : undefined; | |
| 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 | 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 | 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 | 10 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 6 | 11 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| ... | ... | @@ -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 | 80 | export function createAssetEnvironmentAtoms<R, E>( |
| 47 | 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 | 353 | describe("resolveViewedImageAsset", () => { |
| 354 | 354 | const threadId = ThreadId.make("thread-1"); |
| 355 | 355 | |
| 356 | it("loads t3 attachment paths as attachments", () => { | |
| 357 | const attachmentId = | |
| 358 | "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; | |
| 359 | expect( | |
| 360 | resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { | |
| 361 | threadId, | |
| 362 | workspaceRoot: "/workspace", | |
| 363 | }), | |
| 364 | ).toEqual({ | |
| 365 | resource: { _tag: "attachment", attachmentId }, | |
| 366 | alt: `${attachmentId}.png`, | |
| 356 | it("serves t3 attachment paths in place like any other host path", () => { | |
| 357 | const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png"; | |
| 358 | expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({ | |
| 359 | resource: { _tag: "media-file", threadId, path }, | |
| 360 | alt: "11111111-1111-4111-8111-111111111111.png", | |
| 367 | 361 | srcFragment: "", |
| 368 | 362 | }); |
| 369 | 363 | }); |
packages/client-runtime/src/work-log/presentation.ts+13-21| ... | ... | @@ -4,10 +4,8 @@ import { |
| 4 | 4 | type ThreadId, |
| 5 | 5 | type ToolLifecycleItemType, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { | |
| 8 | classifyMarkdownImageSource, | |
| 9 | markdownImageSourceFragment, | |
| 10 | } from "@t3tools/client-runtime/markdown-images"; | |
| 7 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 8 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | |
| 11 | 9 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 12 | 10 | |
| 13 | 11 | export function isWorktreeSetupActivity(kind: string): boolean { |
| ... | ... | @@ -350,15 +348,11 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin |
| 350 | 348 | } |
| 351 | 349 | |
| 352 | 350 | export interface ViewedImageAsset { |
| 353 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; | |
| 351 | readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>; | |
| 354 | 352 | readonly alt: string; |
| 355 | 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 | 356 | export function resolveViewedImageAsset( |
| 363 | 357 | source: string, |
| 364 | 358 | input: { |
| ... | ... | @@ -366,24 +360,22 @@ export function resolveViewedImageAsset( |
| 366 | 360 | readonly workspaceRoot?: string | null | undefined; |
| 367 | 361 | }, |
| 368 | 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 | 365 | const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); |
| 370 | 366 | if (imageSource._tag !== "WorkspaceFile") return null; |
| 371 | ||
| 372 | const path = | |
| 367 | const resolvedFilePath = | |
| 373 | 368 | input.workspaceRoot == null && imageSource.path.startsWith("./") |
| 374 | 369 | ? imageSource.path.slice(2) |
| 375 | 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 { | |
| 381 | resource: attachmentId | |
| 382 | ? { _tag: "attachment", attachmentId } | |
| 383 | : { _tag: "media-file", threadId: input.threadId, path }, | |
| 384 | alt: path.split(/[\\/]/).at(-1) ?? "image", | |
| 385 | srcFragment: markdownImageSourceFragment(source), | |
| 386 | }; | |
| 372 | const media = resolveMediaSource(source, { | |
| 373 | threadId: input.threadId, | |
| 374 | workspaceRoot: input.workspaceRoot, | |
| 375 | resolvedFilePath, | |
| 376 | }); | |
| 377 | if (media === null || media.access !== "environment") return null; | |
| 378 | return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment }; | |
| 387 | 379 | } |
| 388 | 380 | |
| 389 | 381 | function toolGroupActionCount( |