authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 19:37:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 19:37:41-07:00
log922bd692251bc803c12a3fab159efe83c957bb70
treeeb779ea33c073ce16ab93e59bddfea60768d12b2
parent31eeb443305a4e11c8b20a7c8ff2b3f5e841eb0f
signature Signed by PGP key B5690EEEBB952194

refactor(media): unify file and media previews across clients (#9253)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

52 files changed, 1582 insertions(+), 1740 deletions(-)

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