diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index bc5bd51888..9872ff9077 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -17,6 +17,7 @@ function LoadedMediaVideo(props: { readonly uri: string; readonly resolvePlaybackUri?: () => Promise; readonly playRequested: boolean; + readonly onPlay?: () => void; readonly paused: boolean; }) { const focused = useIsFocused(); @@ -80,6 +81,7 @@ function LoadedMediaVideo(props: { contentFit="contain" fullscreenOptions={{ enable: true }} onFullscreenEnter={() => { + props.onPlay?.(); fullscreen.current = true; }} onFullscreenExit={() => { @@ -117,6 +119,7 @@ interface MediaVideoPlayerProps { readonly unavailable?: boolean; readonly expanded?: boolean; readonly autoPlay?: boolean; + readonly onPlay?: () => void; readonly paused?: boolean; readonly actionsSource?: MediaActionsSource; } @@ -138,6 +141,7 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { uri={props.uri ?? playbackUri} resolvePlaybackUri={props.resolvePlaybackUri} playRequested={!props.expanded || props.autoPlay === true} + onPlay={props.onPlay} paused={(props.paused ?? false) || mediaActions.sharing} /> ) : ( @@ -151,7 +155,10 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} // Stays pressable so the long-press menu still opens on a failed or unsigned tile. onPress={() => { - if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + if (props.uri !== null && !props.unavailable) { + props.onPlay?.(); + setPlaybackUri(props.uri); + } }} className="flex-1 items-center justify-center px-4" > diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.test.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.test.tsx new file mode 100644 index 0000000000..3a8e52e5f3 --- /dev/null +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.test.tsx @@ -0,0 +1,496 @@ +// @vitest-environment jsdom +import type { AssetResource, ProjectEntry } from "@t3tools/contracts"; +import { act, type ComponentProps, type PropsWithChildren, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +interface QueryTarget { + readonly kind: "session" | "metadata" | "read" | "entries" | "search"; + readonly environmentId?: string; + readonly input?: { + readonly cwd?: string; + readonly relativePath?: string; + readonly path?: string; + }; +} + +const harness = vi.hoisted(() => ({ + canReadFiles: true, + fileInspectorSupported: false, + renderInspector: undefined as (() => ReactNode) | undefined, + metadata: new Map(), + metadataPending: new Set(), + metadataErrors: new Map(), + entries: new Map>(), + searchResults: new Map>(), + files: new Map(), + requests: [] as QueryTarget[], + assetResources: [] as AssetResource[], + navigation: [] as Array<{ + readonly type: "NAVIGATE" | "PUSH" | "REPLACE"; + readonly name: string; + readonly params: Record; + }>, + goBack: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + View: ({ children }: PropsWithChildren) =>
{children}
, + Pressable: (props: PropsWithChildren<{ onPress?: () => void; accessibilityLabel?: string }>) => ( + + ), + RefreshControl: () => null, +})); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + data: ReadonlyArray; + renderItem: (input: { item: A; index: number }) => ReactNode; + keyExtractor: (item: A) => string; + ListHeaderComponent?: ReactNode; + ListEmptyComponent?: ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 + ? props.ListEmptyComponent + : props.data.map((item, index) => ( +
{props.renderItem({ item, index })}
+ ))} +
+ ), +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("react-native-svg", () => ({ + default: () => null, + Defs: () => null, + LinearGradient: () => null, + Rect: () => null, + Stop: () => null, +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ + canGoBack: () => true, + goBack: harness.goBack, + navigate: (name: string, params: Record) => + harness.navigation.push({ type: "NAVIGATE", name, params }), + dispatch: (action: (typeof harness.navigation)[number]) => harness.navigation.push(action), + }), + StackActions: { + push: (name: string, params: Record) => ({ type: "PUSH", name, params }), + replace: (name: string, params: Record) => ({ type: "REPLACE", name, params }), + }, +})); +vi.mock("@t3tools/client-runtime/state/filesystem", () => ({ + resolveFilesystemReadAccess: () => ({ + canReadFiles: harness.canReadFiles, + isPending: false, + error: null, + }), +})); +vi.mock("../../state/session", () => ({ + environmentSession: { sessionStateAtom: () => ({ kind: "session" }) }, +})); +vi.mock("../../state/projects", () => { + const query = (kind: QueryTarget["kind"]) => (target: Omit) => { + const request = { ...target, kind }; + harness.requests.push(request); + return request; + }; + return { + projectEnvironment: { + fileMetadata: query("metadata"), + readFile: query("read"), + listEntries: query("entries"), + searchEntries: query("search"), + }, + }; +}); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (target: QueryTarget | null) => { + let data: unknown = null; + let error: string | null = null; + let isPending = false; + if (target?.kind === "metadata") { + const path = target.input?.path ?? ""; + isPending = harness.metadataPending.has(path); + error = harness.metadataErrors.get(path) ?? null; + if (!isPending && error === null) { + data = harness.metadata.has(path) ? harness.metadata.get(path) : { kind: "file" }; + } + } else if (target?.kind === "entries" || target?.kind === "search") { + data = { + entries: + (target.kind === "search" ? harness.searchResults : harness.entries).get( + target.input?.cwd ?? "", + ) ?? [], + truncated: false, + }; + } else if (target?.kind === "read") { + const path = target.input?.relativePath ?? ""; + const fullPath = path.startsWith("/") ? path : `${target.input?.cwd}/${path}`; + const file = harness.files.get(fullPath); + if (file?.contents !== undefined) { + data = { contents: file.contents, relativePath: path, byteLength: file.contents.length }; + } else { + error = file?.error ?? `Failed to read workspace file '${path}'.`; + } + } + return { data, error, isPending, isSuccess: !isPending && error === null, refresh: () => {} }; + }, +})); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), +})); +vi.mock("../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { id: "thread", environmentId: "environment" }, + selectedThreadProject: { title: "Project", workspaceRoot: "/workspace" }, + }), +})); +vi.mock("../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/workspace" }), +})); +vi.mock("../../state/queries", () => ({ useDebouncedValue: (value: string) => value })); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../native/native-layout-metrics", () => ({ + useNativeColumnLayoutMetrics: () => null, +})); +vi.mock("../../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) })); +vi.mock("../../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: () => {} })); +vi.mock("../../lib/openExternalUrl", () => ({ tryOpenExternalUrl: () => {} })); +vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ + useAdaptiveWorkspacePaneRole: () => {}, + useRegisterWorkspaceInspector: (renderInspector: (() => ReactNode) | undefined) => { + harness.renderInspector = renderInspector; + }, + useAdaptiveWorkspaceLayout: () => ({ + fileInspector: { supported: harness.fileInspectorSupported }, + layout: { usesSplitView: false }, + panes: { auxiliaryPaneVisible: false }, + toggleAuxiliaryPane: () => {}, + }), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + setCodeWordBreak: () => {}, + }), +})); +vi.mock("../../components/ui/AppBar", () => ({ + AppBar: (props: { + title: string; + subtitle?: string; + onBack?: () => void; + search?: { value: string; onChangeText: (value: string) => void }; + }) => ( +
+ +

{props.title}

+ {props.search ? ( + props.search!.onChangeText(event.currentTarget.value)} + /> + ) : null} + {props.subtitle} +
+ ), +})); +vi.mock("../../components/ui/AppText", () => ({ + AppText: ({ children }: PropsWithChildren) => {children}, +})); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null })); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: PropsWithChildren) =>
{children}
, +})); +vi.mock("../../components/ui/EmptyState", () => ({ + EmptyState: (props: { title: string; detail?: string }) => ( +
+ {props.title}: {props.detail} +
+ ), +})); +vi.mock("../../components/LoadingScreen", () => ({ + LoadingScreen: (props: { message: string }) =>
{props.message}
, +})); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../threads/ThreadRouteScreen", () => ({ ThreadRouteScreen: () => null })); +vi.mock("../threads/thread-inspector-content-stack", () => ({ + ThreadInspectorContentStack: (props: { renderFiles: () => ReactNode }) => props.renderFiles(), +})); +vi.mock("./thread-file-navigator-pane", () => ({ + ThreadFileNavigatorPane: (props: { + cwd: string; + selectedPath: string | null; + onSelectFile: (path: string) => void; + }) => ( + + ), +})); +vi.mock("./preload-workspace-file", () => ({ preloadWorkspaceFileContents: () => {} })); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () =>
Audio preview
})); +vi.mock("./FileMarkdownPreview", () => ({ + FileMarkdownPreview: (props: { markdown: string }) =>
{props.markdown}
, +})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { contents: string }) =>
{props.contents}
, +})); +vi.mock("./WorkspaceFileImagePreview", () => ({ + WorkspaceFileImagePreview: () =>
Image preview
, +})); +vi.mock("./WorkspaceFileVideoPreview", () => ({ + WorkspaceFileVideoPreview: () =>
Video preview
, +})); +vi.mock("./WorkspaceFileWebPreview", () => ({ + WorkspaceFileWebPreview: () =>
Document preview
, +})); +vi.mock("./WorkspaceFilePreviewError", () => ({ + WorkspaceFilePreviewError: () =>
Preview unavailable
, +})); +vi.mock("../../state/assets", () => ({ + useAssetUrlState: (_environmentId: string | null, resource: AssetResource | null) => { + if (resource === null) return { _tag: "Failure", reason: "unavailable" }; + harness.assetResources.push(resource); + return { _tag: "Success", url: "https://example.test/signed-preview" }; + }, + useRefreshAssetUrl: () => async () => null, +})); + +import { ThreadFileScreen } from "./ThreadFilesRouteScreen"; +import { fileRoutePathSegments } from "./filePath"; + +let root: Root | null = null; +let container: HTMLDivElement; + +function RegisteredInspector() { + return harness.renderInspector?.(); +} + +function renderRoute(params: ComponentProps["route"]["params"]) { + root ??= createRoot(container); + act(() => { + root!.render( + <> + + + , + ); + }); +} + +function renderFile(path: string, draft = false) { + renderRoute({ + environmentId: "environment", + ...(draft ? { cwd: "/workspace", projectName: "Project" } : { threadId: "thread" }), + path: fileRoutePathSegments(path), + }); +} + +function followNavigation() { + const params = harness.navigation.at(-1)?.params; + const path = params?.path; + if (!Array.isArray(path) || !path.every((segment) => typeof segment === "string")) { + throw new Error("Expected a file navigation destination."); + } + renderRoute({ + environmentId: String(params?.environmentId), + ...(typeof params?.threadId === "string" ? { threadId: params.threadId } : {}), + ...(typeof params?.cwd === "string" ? { cwd: params.cwd } : {}), + ...(typeof params?.projectName === "string" ? { projectName: params.projectName } : {}), + path, + }); +} + +function click(label: string) { + const button = [...container.querySelectorAll("button")].find( + (item) => item.getAttribute("aria-label") === label || item.textContent === label, + ); + expect(button, `Expected a visible ${label} button`).toBeDefined(); + act(() => button!.click()); +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + harness.canReadFiles = true; + harness.fileInspectorSupported = false; + harness.renderInspector = undefined; + harness.metadata.clear(); + harness.metadataPending.clear(); + harness.metadataErrors.clear(); + harness.entries.clear(); + harness.searchResults.clear(); + harness.files.clear(); + harness.requests = []; + harness.assetResources = []; + harness.navigation = []; + harness.goBack.mockClear(); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); +}); + +describe("ThreadFileScreen directory links", () => { + it.each([false, true])( + "keeps workspace HTML sibling assets relative to the browser root (draft: %s)", + (draft) => { + harness.fileInspectorSupported = true; + harness.entries.set("/workspace", [ + { kind: "file", path: "README.md" }, + { kind: "file", path: "site/index.html" }, + ]); + harness.files.set("/workspace/README.md", { contents: "Workspace readme" }); + renderFile("README.md", draft); + click("site/index.html"); + followNavigation(); + expect(container.textContent).toContain("Document preview"); + expect(harness.assetResources.at(-1)).toEqual( + draft + ? { _tag: "draft-workspace-file", cwd: "/workspace", path: "site/index.html" } + : { _tag: "workspace-file", threadId: "thread", path: "/workspace/site/index.html" }, + ); + }, + ); + + it.each([false, true])( + "retains an external directory root through tablet sibling selection (draft: %s)", + (draft) => { + const folder = "/tmp/images.png"; + harness.fileInspectorSupported = true; + harness.metadata.set(folder, { kind: "directory" }); + harness.entries.set(folder, [ + { kind: "file", path: "report.txt" }, + { kind: "file", path: "screenshot.png" }, + ]); + harness.files.set(`${folder}/report.txt`, { contents: "External report" }); + + renderFile(folder, draft); + expect(container.textContent).toContain("report.txt"); + expect(harness.assetResources).toEqual([]); + click("report.txt"); + expect(harness.navigation.at(-1)?.name).toBe(draft ? "NewTaskFile" : "ThreadFile"); + followNavigation(); + expect(container.textContent).toContain("External report"); + expect(container.querySelector('aside [aria-current="page"]')?.textContent).toBe( + "report.txt", + ); + + click("screenshot.png"); + followNavigation(); + expect(container.textContent).toContain("Image preview"); + expect(container.querySelector('aside [aria-current="page"]')?.textContent).toBe( + "screenshot.png", + ); + expect(harness.assetResources.at(-1)).toEqual( + draft + ? { _tag: "draft-workspace-file", cwd: folder, path: "screenshot.png" } + : { _tag: "media-file", threadId: "thread", path: `${folder}/screenshot.png` }, + ); + }, + ); + + it("opens a workspace-relative directory after metadata arrives, then reads its child", () => { + const folder = "/workspace/reports"; + harness.metadataPending.add(folder); + harness.entries.set(folder, [{ kind: "file", path: "guide.txt" }]); + harness.files.set(`${folder}/guide.txt`, { contents: "Workspace guide" }); + + renderFile("reports"); + expect(container.textContent).toContain("Loading file..."); + expect(container.textContent).not.toContain("unavailable"); + harness.metadataPending.delete(folder); + harness.metadata.set(folder, { kind: "directory" }); + renderFile("reports"); + click("guide.txt"); + expect(harness.navigation.at(-1)?.type).toBe("PUSH"); + followNavigation(); + expect(container.textContent).toContain("Workspace guide"); + click("Back"); + expect(harness.goBack).toHaveBeenCalledOnce(); + }); + + it("finds nested children inside the linked directory and opens the result", () => { + const folder = "/tmp/reports"; + harness.metadata.set(folder, { kind: "directory" }); + harness.entries.set(folder, [{ kind: "file", path: "unrelated.txt" }]); + harness.searchResults.set(folder, [{ kind: "file", path: "nested/guide.txt" }]); + harness.files.set(`${folder}/nested/guide.txt`, { contents: "Nested guide" }); + renderFile(folder); + const search = container.querySelector("input")!; + act(() => { + search.value = "guide"; + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).not.toContain("unrelated.txt"); + click("nested/guide.txt"); + followNavigation(); + expect(container.textContent).toContain("Nested guide"); + }); + + it("opens an empty filesystem root as a directory", () => { + harness.metadata.set("/", { kind: "directory" }); + renderFile("/"); + expect(container.textContent).toContain("The workspace is empty."); + expect(container.textContent).not.toContain("File unavailable"); + expect(harness.requests.filter((request) => request.kind === "read")).toEqual([]); + expect(harness.assetResources).toEqual([]); + }); + + it("keeps cached text visible during metadata refresh and after metadata failure", () => { + harness.files.set("/workspace/report.txt", { contents: "Cached report" }); + renderFile("report.txt"); + harness.metadataPending.add("/workspace/report.txt"); + renderFile("report.txt"); + expect(container.textContent).toContain("Cached report"); + expect(container.textContent).not.toContain("Loading file..."); + harness.metadataPending.clear(); + harness.metadataErrors.set("/workspace/report.txt", "Metadata unavailable"); + renderFile("report.txt"); + expect(container.textContent).toContain("Cached report"); + }); + + it("leaves loading and reports a failed read when metadata also fails", () => { + harness.metadataPending.add("/workspace/missing.txt"); + renderFile("missing.txt"); + expect(container.textContent).toContain("Loading file..."); + harness.metadataPending.clear(); + harness.metadataErrors.set("/workspace/missing.txt", "Metadata unavailable"); + harness.files.set("/workspace/missing.txt", { error: "The file no longer exists." }); + renderFile("missing.txt"); + expect(container.textContent).toContain("The file no longer exists."); + expect(container.textContent).not.toContain("Loading file..."); + }); + + it("shows permission denial without reading metadata, contents, directories, or assets", () => { + harness.canReadFiles = false; + renderFile("/tmp/images.png"); + expect(container.textContent).toContain("Files unavailable"); + expect(container.textContent).not.toContain("Loading file..."); + expect(harness.requests).toEqual([]); + expect(harness.assetResources).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a95f8ea8e9..5b5932a4b0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -58,10 +58,13 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, fileHeaderSubtitle, + fileRoutePathSegments, isAudioPreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, + resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, } from "./filePath"; import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; @@ -183,6 +186,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { } function normalizeRoutePath(value: string | string[] | undefined): string | null { + if (Array.isArray(value) && value.length === 1 && value[0] === "") return "/"; const path = Array.isArray(value) ? value.join("/") : value; if (path === undefined || path.trim().length === 0) { return null; @@ -585,6 +589,32 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); + const fileAccessSession = useEnvironmentQuery( + environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, + ); + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; + const targetPath = + cwd !== null && relativePath !== null ? resolveWorkspaceFilePath(cwd, relativePath) : null; + const metadata = useEnvironmentQuery( + canReadFiles && environmentId !== null && targetPath !== null + ? projectEnvironment.fileMetadata({ environmentId, input: { path: targetPath } }) + : null, + ); + const metadataPending = !metadata.isSuccess && metadata.data === null && metadata.error === null; + const directoryPath = metadata.data?.kind === "directory" ? targetPath : null; + const [searchQuery, setSearchQuery] = useState(""); + const entriesQuery = useFileTreeEntries({ + environmentId, + cwd: canReadFiles ? directoryPath : null, + searchQuery, + }); const [modeOverride, setModeOverride] = useState<{ readonly path: string; readonly mode: FileViewMode; @@ -612,7 +642,12 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const resolvedActiveMode = isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source"; const assetPreviewPath = - isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null; + canReadFiles && + !metadataPending && + directoryPath === null && + (isBrowserFile || isImageFile || isVideoFile || isAudioFile) + ? relativePath + : null; const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, @@ -664,20 +699,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { }; const needsFileContents = relativePath !== null && + directoryPath === null && !isVideoFile && !isAudioFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); - const fileAccessSession = useEnvironmentQuery( - environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, - ); - const fileEnvironment = useEnvironmentPresentation(environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); - const { canReadFiles } = fileAccess; const fileQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && @@ -692,38 +717,50 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); const fileData = fileQuery.data as ProjectReadFileResult | null; + const fileBrowserCwd = directoryPath ?? cwd; + const fileBrowserName = directoryPath ?? projectName; + const handleSelectFile = (path: string) => { - const segments = path.split("/").filter(Boolean); - // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a - // thread to resolve, so a draft stays on its own route and carries its workspace along. + if (environmentId === null || fileBrowserCwd === null) return; + // Relative asset paths keep HTML subresources accessible. Thread assets resolve + // against the thread workspace, so only linked browser roots need absolute paths. + const linkedRoot = + threadId !== null && (directoryPath !== null || firstRouteParam(params.cwd) !== null); + const destination = { + environmentId: String(environmentId), + ...(threadId === null ? {} : { threadId: String(threadId) }), + ...(threadId === null || linkedRoot + ? { cwd: fileBrowserCwd, projectName: fileBrowserName } + : {}), + path: fileRoutePathSegments( + linkedRoot ? resolveWorkspaceFilePath(fileBrowserCwd, path) : path, + ), + }; if (threadId === null) { - navigation.dispatch( - StackActions.push("NewTaskFile", { - environmentId: String(environmentId), - ...(cwd === null ? {} : { cwd }), - projectName, - path: segments, - }), - ); - return; + navigation.dispatch(StackActions.push("NewTaskFile", destination)); + } else if (directoryPath !== null) { + // Keep the directory on the stack so Back returns to the linked browser. + navigation.dispatch(StackActions.push("ThreadFile", destination)); + } else { + navigation.navigate("ThreadFile", destination); } - navigation.navigate("ThreadFile", { - environmentId: String(environmentId), - threadId: String(threadId), - path: segments, - }); }; const renderInspector = (headerInset: number) => - fileInspector.supported && environmentId !== null && cwd !== null ? ( + fileInspector.supported && environmentId !== null && fileBrowserCwd !== null ? ( ) : undefined; + const safeAreaInsets = useSafeAreaInsets(); const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; const renderWorkspaceInspector = () => ( @@ -736,7 +773,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); const fileMenuActions = (() => { - if (relativePath === null) return []; + if (relativePath === null || directoryPath !== null) return []; const canToggleMode = canPreview && !isImageFile && !isVideoFile && !isAudioFile; return [ canToggleMode @@ -877,6 +914,38 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); } + if (directoryPath !== null) { + return ( + + + + + + + + ); + } + const headerSubtitle = fileHeaderSubtitle(projectName, relativePath); return ( @@ -892,25 +961,29 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { actions={fileMenuActions} /> - fileQuery.refresh()} - /> + {metadataPending && fileData === null ? ( + + ) : ( + fileQuery.refresh()} + /> + )} ({ + focused: true, + canRead: true, + navigate: vi.fn(), + query: vi.fn(), +})); + +function Box({ children }: PropsWithChildren) { + return
{children}
; +} + +vi.mock("react-native", () => ({ + Platform: { OS: "ios", select: (values: { ios: unknown }) => values.ios }, + View: Box, + Text: Box, + ScrollView: Box, + Pressable: ({ + children, + onPress, + accessibilityLabel, + }: PropsWithChildren<{ onPress?: () => void; accessibilityLabel?: string }>) => ( + + ), + Image: () => null, + ActivityIndicator: () => null, + StyleSheet: { create: (styles: unknown) => styles, absoluteFill: {} }, + useWindowDimensions: () => ({ width: 400, height: 800, fontScale: 1 }), +})); +vi.mock("react-native-reanimated", () => { + const animation = { delay: () => animation, duration: () => animation }; + return { default: { View: Box }, FadeIn: animation, FadeInUp: animation }; +}); +vi.mock("@legendapp/list/keyboard", () => ({ + KeyboardAwareLegendList: (props: { + data: ThreadFeedEntry[]; + renderItem: (info: { item: ThreadFeedEntry; index: number }) => ReactNode; + }) => ( + <> + {props.data.map((item, index) => ( +
{props.renderItem({ item, index })}
+ ))} + + ), +})); +vi.mock("@legendapp/list/react-native", () => ({ useViewabilityAmount: () => {} })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ navigate: harness.navigate }), + useFocusEffect: (effect: () => void | (() => void)) => { + useEffect(() => (harness.focused ? effect() : undefined), [effect, harness.focused]); + }, +})); +vi.mock("@react-navigation/elements", () => ({ HeaderHeightContext: createContext(0) })); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} })); +vi.mock("expo-crypto", () => ({ randomUUID: () => "test-id" })); +vi.mock("expo-image", () => ({ Image: () => null })); +vi.mock("uniwind", () => ({ useResolveClassNames: () => ({}) })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: (...args: unknown[]) => harness.query(...args), +})); +vi.mock("../../state/projects", () => ({ + projectEnvironment: { fileMetadata: (input: unknown) => input }, +})); +vi.mock("../../state/session", () => ({ readEnvironmentScope: () => harness.canRead })); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("../../state/entities", () => ({ useLiveThreadLinkLabels: (text: string) => text })); +vi.mock("../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ selectedThread: null }), +})); +vi.mock("../../state/assets", () => ({})); +vi.mock("../../state/use-atom-query-runner", () => ({})); +vi.mock("../../state/threads", () => ({})); +vi.mock("../../state/use-atom-command", () => ({})); +vi.mock("../../state/v2-item-support", () => ({})); +vi.mock("../../native/NativeWorkspaceColumns", () => ({ + useNativeWorkspaceColumnsSupported: () => false, +})); +vi.mock("../../native/native-layout-metrics", () => ({ useDeviceType: () => "phone" })); +vi.mock("../../native/useOpenPullRequest", () => ({ useOpenPullRequest: () => () => {} })); +vi.mock("../../native/useMarkdownImageSource", () => ({ + useMarkdownImageSource: () => () => null, +})); +vi.mock("../../native/SelectableMarkdownText", () => ({ + SelectableMarkdownText: (props: { + markdown: string; + onLinkPress: (href: string) => void; + renderImage: (image: { href: string; alt: string }) => ReactNode; + }) => ( + <> + {[...props.markdown.matchAll(/(!?)\[([^\]]+)\]\(([^)]+)\)/g)].map( + ([match, embed, label, href]) => + embed ? ( +
{props.renderImage({ href: href!, alt: label! })}
+ ) : ( + + ), + )} + + ), +})); +vi.mock("../../lib/useUniwindTheme", () => ({ + useUniwindTheme: () => ({ "--color-border": "#000000", "--color-user-bubble": "#ffffff" }), +})); +vi.mock("../../lib/markdownFileIcons", () => ({})); +vi.mock("../../lib/markdownLinkIcons", () => ({})); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("../../lib/openExternalUrl", () => ({})); +vi.mock("../../lib/attachmentDownload", () => ({})); +vi.mock("../../lib/composerContextClipboard", () => ({})); +vi.mock("../prompt-links/prompt-link-actions", () => ({})); +vi.mock("../review/ReviewCommentCard", () => ({ useReviewCommentColors: () => ({}) })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ appearance: { baseFontSize: 14 }, themeAppearance: "light" }), +})); +vi.mock("../../components/ui/AppText", () => ({ AppText: Box })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null })); +vi.mock("../../components/ui/NativePresentation", () => ({ PresentationSource: Box })); +vi.mock("../../components/ui/Dialog", () => ({})); +vi.mock("../../components/ui/Menu", () => ({ Menu: Box })); +vi.mock("../../components/FilePreviewGallery", () => ({ FilePreviewGallery: Box })); +vi.mock("../../components/FilePreviewModal", () => ({ + FilePreviewModal: ({ + source, + onRequestClose, + }: { + source: FilePreviewSource | null; + onRequestClose: () => void; + }) => + source ? ( +
+ {source.name} + +
+ ) : null, +})); +vi.mock("../../components/VideoPreviewModal", () => ({ + VideoPreviewModal: ({ source }: { source: VideoPreviewSource | null }) => + source ?
{"name" in source ? source.name : "Video"}
: null, +})); +vi.mock("../../components/VideoAttachmentTile", () => ({})); +vi.mock("../../components/MediaVideoPlayer", () => ({})); +vi.mock("../../components/ComposerContextSheet", () => ({})); +vi.mock("../../components/PierreEntryIcon", () => ({})); +vi.mock("./ThreadMarkdownImage", () => ({ + MarkdownImageAvailableWidthContext: createContext(0), + ThreadMarkdownImageView: ({ + alt, + uri, + onPressPreview, + }: { + alt: string; + uri: string; + onPressPreview: (source: FilePreviewSource) => void; + }) => , +})); +vi.mock("./ThreadMarkdownVideo", () => ({ ThreadMediaVisibleContext: createContext(false) })); +vi.mock("./thread-context-divider", () => ({})); +vi.mock("./thread-handoff-row", () => ({})); +vi.mock("./SecretRequestCard", () => ({})); +vi.mock("./worktree-setup-card", () => ({})); +vi.mock("./CopyTextButton", () => ({})); +vi.mock("./thread-work-log", () => ({ THREAD_DISCLOSURE_TRANSITION_MS: 0 })); +vi.mock("./HtmlRenderWebView", () => ({})); +vi.mock("./McpAppWebView", () => ({})); +vi.mock("./markdownCodeHighlightState", () => ({})); +vi.mock("./thread-workflow-opening", () => ({ useWorkflowOpening: () => null })); +vi.mock("./useFileChipShare", () => ({ useFileChipShare: () => () => {} })); + +import { ThreadFeed, type ThreadFeedProps } from "./ThreadFeed"; + +let root: Root; +let container: HTMLDivElement; +let props: ThreadFeedProps; + +function renderFeed(overrides: Partial = {}) { + props = { ...props, ...overrides }; + act(() => root.render()); +} +function click(label: string) { + const button = [...container.querySelectorAll("button")].find( + (button) => button.textContent === label, + ); + expect(button, `Expected ${label} button`).toBeDefined(); + act(() => button!.click()); +} +function pendingMetadata() { + const deferred = Promise.withResolvers<{ + _tag: "Success"; + value: { kind: "directory" | "file" }; + }>(); + // Intentionally completes even if aborted: the consumer must ignore late responses. + harness.query.mockReturnValueOnce(deferred.promise); + return { + async finish(kind: "directory" | "file") { + await act(async () => { + deferred.resolve({ _tag: "Success", value: { kind } }); + await deferred.promise; + }); + }, + async fail() { + await act(async () => { + deferred.reject(new Error("Metadata failed")); + await deferred.promise.catch(() => {}); + }); + }, + }; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + harness.focused = true; + harness.canRead = true; + harness.query.mockReset(); + harness.navigate.mockReset(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const threadId = ThreadId.make("thread"); + const createdAt = "2026-01-01T00:00:00Z"; + props = { + environmentId: EnvironmentId.make("environment"), + threadId, + threadTitle: "Files", + workspaceRoot: "/workspace", + queuedMessages: [], + dispatchingMessageId: null, + onEditPendingMessage: null, + contentPresentation: { kind: "ready" }, + agentLabel: "Agent", + latestRun: null, + activeWorkStartedAt: null, + listRef: { current: null }, + freeze: { value: false } as ThreadFeedProps["freeze"], + contentInsetEndAdjustment: { value: 0 } as ThreadFeedProps["contentInsetEndAdjustment"], + anchorMessageId: null, + submittedMessageId: null, + feed: [ + { + id: "message", + type: "message", + createdAt, + message: { + id: MessageId.make("message"), + role: "assistant", + runId: null, + streaming: false, + sourceThreadId: threadId, + visibility: "local", + createdAt, + updatedAt: createdAt, + attachments: [], + text: "[Root](/) [Folder](/tmp/images.png) [Video](./movie.mp4) [PDF](./report.pdf) [Text](./report.txt) [Remote](https://example.test/direct.png) ![Inline](https://example.test/inline.png)", + }, + }, + ], + }; +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("ThreadFeed file links", () => { + it("opens the filesystem root from a conversation link", () => { + renderFeed(); + click("Root"); + expect(harness.navigate).toHaveBeenCalledExactlyOnceWith("ThreadFile", { + environmentId: "environment", + threadId: "thread", + path: [""], + }); + }); + + it("waits for local metadata and routes media-named directories with their absolute path", async () => { + const pending = pendingMetadata(); + renderFeed(); + click("Folder"); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(harness.navigate).not.toHaveBeenCalled(); + await pending.finish("directory"); + expect(harness.navigate).toHaveBeenCalledExactlyOnceWith("ThreadFile", { + environmentId: "environment", + threadId: "thread", + path: ["", "tmp", "images.png"], + }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("retains local video and PDF previews and opens direct media without a metadata query", async () => { + const pending = pendingMetadata(); + renderFeed(); + click("PDF"); + await pending.finish("file"); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("report.pdf"); + click("Close preview"); + click("Remote"); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("direct.png"); + expect(harness.query).toHaveBeenCalledTimes(1); + click("Close preview"); + const video = pendingMetadata(); + click("Video"); + await video.finish("file"); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("movie.mp4"); + expect(harness.navigate).not.toHaveBeenCalled(); + }); + + it.each(["Text", "Remote", "Inline"])( + "ignores a delayed directory result after opening %s", + async (next) => { + const pending = pendingMetadata(); + renderFeed(); + click("Folder"); + click(next); + if (next === "Text") + expect(harness.navigate).toHaveBeenCalledWith( + "ThreadFile", + expect.objectContaining({ path: ["report.txt"] }), + ); + else + expect(container.querySelector('[role="dialog"]')?.textContent).toContain( + next === "Remote" ? "direct.png" : "Inline", + ); + const navigation = harness.navigate.mock.calls.slice(); + const preview = container.querySelector('[role="dialog"]')?.textContent; + await pending.finish("directory"); + expect(harness.navigate.mock.calls).toEqual(navigation); + expect(container.querySelector('[role="dialog"]')?.textContent).toBe(preview); + }, + ); + + it.each(["thread", "environment", "workspace", "blur"])( + "ignores delayed results after %s changes", + async (change) => { + const pending = pendingMetadata(); + renderFeed(); + click("Folder"); + if (change === "blur") harness.focused = false; + renderFeed( + change === "thread" + ? { threadId: ThreadId.make("other") } + : change === "environment" + ? { environmentId: EnvironmentId.make("other") } + : change === "workspace" + ? { workspaceRoot: "/other-worktree" } + : { threadTitle: "Blurred" }, + ); + if (change === "blur") await pending.fail(); + else await pending.finish(change === "environment" ? "file" : "directory"); + expect(harness.navigate).not.toHaveBeenCalled(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }, + ); + + it("keeps the latest local preview when an older metadata request fails", async () => { + const older = pendingMetadata(); + renderFeed(); + click("Folder"); + const latest = pendingMetadata(); + click("Video"); + await latest.finish("file"); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("movie.mp4"); + await older.fail(); + expect(container.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("movie.mp4"); + expect(harness.navigate).not.toHaveBeenCalled(); + }); + + it("falls back to a preview after metadata failure and skips denied metadata access", async () => { + const pending = pendingMetadata(); + renderFeed(); + click("Folder"); + await pending.fail(); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("images.png"); + click("Close preview"); + harness.canRead = false; + click("PDF"); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain("report.pdf"); + expect(harness.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 196518b005..36c1b94bb0 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -22,6 +22,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { type OrchestrationMessageContext, + AuthFilesystemReadScope, ThreadId, type ChatAttachment, type ChatFileAttachment, @@ -50,7 +51,11 @@ import { import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + executeAtomQuery, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, @@ -211,7 +216,8 @@ import { useRefreshAssetUrl, } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { usePreparedConnection } from "../../state/session"; +import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { projectEnvironment } from "../../state/projects"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; @@ -720,6 +726,7 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { } interface MarkdownLinkHandlers { + readonly onBeforeOpen: () => void; readonly onLinkPress: (href: string) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction: (href: string, actionId: string) => void; @@ -1484,6 +1491,7 @@ function UserMessageContent(props: UserMessageContentProps) { return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; }); const onLinkPress = (href: string) => { + props.linkHandlers.onBeforeOpen(); const reference = parseComposerContextHref(href); if (!reference) return props.linkHandlers.onLinkPress?.(href); const record = props.context?.records.find( @@ -1739,16 +1747,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const markdownLinkRequest = useRef(null); + useFocusEffect(useCallback(() => () => markdownLinkRequest.current?.abort(), [])); const fileShareSourceIdentifier = useId(); const shareFileChip = useFileChipShare( props.environmentId, props.threadId, fileShareSourceIdentifier, ); - useEffect(() => { + useLayoutEffect(() => { + markdownLinkRequest.current?.abort(); setExpandedVideo(null); setExpandedFile(null); - }, [props.environmentId, props.threadId, props.contentPresentation.kind]); + }, [props.environmentId, props.threadId, props.workspaceRoot, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentLeftInset = props.contentSideInsets?.left ?? 0; const contentRightInset = props.contentSideInsets?.right ?? 0; @@ -1787,7 +1798,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const iconSubtleColor = theme["--color-icon-subtle"]; const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; + const cancelMarkdownLinkRequest = () => markdownLinkRequest.current?.abort(); + const onPressPreview = (source: FilePreviewSource) => { + cancelMarkdownLinkRequest(); + setExpandedFile((current) => current ?? source); + }; const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + cancelMarkdownLinkRequest(); + const request = new AbortController(); + markdownLinkRequest.current = request; const linkedThreadId = parseThreadLinkHref(href); if (linkedThreadId) { navigation.navigate("Thread", { @@ -1796,6 +1815,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); return; } + if (href === "/") { + void Haptics.selectionAsync(); + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(href), + }); + return; + } const media = resolveMarkdownMediaPreview(href, { environmentId: props.environmentId, threadId: props.threadId, @@ -1804,10 +1832,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); if (media) { void Haptics.selectionAsync(); - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); + const openPreview = () => { + if (request.signal.aborted) return; + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); + } + }; + const resource = "resource" in media.source ? media.source.resource : null; + if ( + resource?._tag === "media-file" && + readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: resource.path }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then((result) => { + if (request.signal.aborted) return; + if (result._tag === "Success" && result.value?.kind === "directory") { + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(resource.path), + }); + } else { + openPreview(); + } + }, openPreview); } else { - setExpandedFile((current) => current ?? media.source); + openPreview(); } return; } @@ -1854,6 +1912,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { workspaceRoot: props.workspaceRoot ?? null, }); const markdownLinkHandlers: MarkdownLinkHandlers = { + onBeforeOpen: cancelMarkdownLinkRequest, onLinkPress: onMarkdownLinkPress, onImagePress: (href) => onMarkdownLinkPress(href, true), resolveImageSource, @@ -1890,6 +1949,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (media?.kind === "video") { return ( @@ -1910,7 +1970,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { unavailable={false} alt={image.alt} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); } @@ -1928,7 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alt={image.alt} srcFragment={markdownImageSourceFragment(image.href)} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); }; @@ -1957,7 +2017,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? { ...actionsSource, resource: viewedImage.resource } : undefined } - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ) : null; }; @@ -2288,10 +2348,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); }; - const onPressPreview = (source: FilePreviewSource) => { - setExpandedFile((current) => current ?? source); - }; const onPressVideo = (attachment: ChatFileAttachment, sourceIdentifier: string) => { + cancelMarkdownLinkRequest(); setExpandedVideo( (current) => current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index e01f6ae3b2..f65e049eeb 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -12,6 +12,7 @@ export const ThreadMediaVisibleContext = createContext(false); export function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource; readonly thumbnailVisible?: boolean; + readonly onPlay?: () => void; }) { const { source } = props; const feedVisible = useContext(ThreadMediaVisibleContext); @@ -28,6 +29,7 @@ export function ThreadMarkdownVideo(props: { const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); return (