authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 23:06:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 23:06:13-07:00
loga495385584276d0e568df23646a49ce8b40a4707
tree8c1438cd8a1ce9fc098d635b8706c51cf31943ed
parentb7465a3bc993e7f10f4ec7a469759b95eb4c2f2a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): save linked media from chat (#10271)


4 files changed, 163 insertions(+), 9 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+15-6
......@@ -178,6 +178,7 @@ import {
178178 resolveWorkspaceRelativeFilePath,
179179} from "../files/filePath";
180180import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
181import { useFileChipShare } from "./useFileChipShare";
181182import {
182183 MarkdownImageAvailableWidthContext,
183184 ThreadMarkdownImage,
......@@ -1964,6 +1965,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19641965 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
19651966 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
19661967 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1968 const fileShareSourceIdentifier = useId();
1969 const shareFileChip = useFileChipShare(
1970 props.environmentId,
1971 props.threadId,
1972 fileShareSourceIdentifier,
1973 );
19671974 useEffect(() => {
19681975 setExpandedVideo(null);
19691976 setExpandedFile(null);
......@@ -2116,10 +2123,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21162123 case "open-file":
21172124 onMarkdownLinkPress(href);
21182125 return;
2126 case "save":
2127 shareFileChip(target);
2128 return;
21192129 }
21202130 },
21212131 }),
2122 [onMarkdownLinkPress, props.workspaceRoot],
2132 [onMarkdownLinkPress, props.workspaceRoot, shareFileChip],
21232133 );
21242134 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
21252135 (image) => {
......@@ -2710,7 +2720,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27102720 }
27112721
27122722 return (
2713 <>
2723 <PresentationSource identifier={fileShareSourceIdentifier} style={{ flex: 1 }}>
27142724 <View className="flex-1" onLayout={handleViewportLayout}>
27152725 <View className="flex-1">
27162726 <KeyboardAwareLegendList
......@@ -2869,10 +2879,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
28692879 />
28702880 </View>
28712881 ) : null}
2882 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2883 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
28722884 </View>
2873
2874 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2875 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
2876 </>
2885 </PresentationSource>
28772886 );
28782887});
apps/mobile/src/features/threads/fileChipMenu.test.ts+44-1
......@@ -1,6 +1,7 @@
1import { ThreadId } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23
3import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu";
4import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu";
45
56describe("resolveFileChipTarget", () => {
67 it("resolves a workspace-relative link to both paths", () => {
......@@ -42,3 +43,45 @@ describe("fileChipMenu", () => {
4243 ]);
4344 });
4445});
46
47describe("file chip downloads", () => {
48 const threadId = ThreadId.make("thread-1");
49
50 it.each([
51 [
52 "/tmp/maria-counter/maria-counter-final.mp4",
53 "/tmp/maria-counter/maria-counter-final.mp4",
54 "video/mp4",
55 ],
56 ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"],
57 ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"],
58 ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"],
59 ])("offers a host download for %s", (href, path, mimeType) => {
60 const target = resolveFileChipTarget(href, "/repo")!;
61 expect(fileChipMenu(target).actions).toContainEqual({
62 id: "save",
63 title: "Save or share",
64 });
65 expect(fileChipShareSource(target, threadId)).toEqual({
66 name: path.split("/").at(-1),
67 mimeType,
68 resource: { _tag: "media-file", threadId, path },
69 });
70 });
71
72 it("retains the thread context for a relative file without a known workspace root", () => {
73 expect(
74 fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId),
75 ).toMatchObject({
76 resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" },
77 });
78 });
79
80 it("does not offer downloads the host asset endpoint cannot serve", () => {
81 for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) {
82 const target = resolveFileChipTarget(href, "/repo")!;
83 expect(fileChipShareSource(target, threadId)).toBeNull();
84 expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false);
85 }
86 });
87});
apps/mobile/src/features/threads/fileChipMenu.ts+34-2
......@@ -1,5 +1,8 @@
1import { fileBasename } from "@t3tools/client-runtime/markdown-links";
2import type { ThreadId } from "@t3tools/contracts";
13import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
24import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";
5import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";
36
47import {
58 isAbsolutePath,
......@@ -7,7 +10,7 @@ import {
710 resolveWorkspaceRelativeFilePath,
811} from "../files/filePath";
912
10export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file";
13export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save";
1114
1215export interface FileChipTarget {
1316 /** The host path, when the link is absolute or the workspace root is known. */
......@@ -36,7 +39,28 @@ export function resolveFileChipTarget(
3639 };
3740}
3841
39/** The same actions the web file chip offers on right-click. Opening is what a tap does. */
42function fileChipMetadata(target: FileChipTarget) {
43 const path = target.fullPath ?? target.relativePath;
44 if (!path) return null;
45 const name = fileBasename(path);
46 const dot = name.lastIndexOf(".");
47 const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot));
48 return mimeType ? { path, name, mimeType } : null;
49}
50
51/** Use literal resolved paths so encoded filename characters are not decoded twice. */
52export function fileChipShareSource(target: FileChipTarget, threadId: ThreadId) {
53 const metadata = fileChipMetadata(target);
54 return metadata
55 ? {
56 name: metadata.name,
57 mimeType: metadata.mimeType,
58 resource: { _tag: "media-file" as const, threadId, path: metadata.path },
59 }
60 : null;
61}
62
63/** Saving is available for the media and documents the host asset endpoint can serve. */
4064export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu {
4165 return {
4266 title: target.fullPath ?? target.relativePath ?? "",
......@@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu {
4468 ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []),
4569 ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []),
4670 { id: "open-file", title: "Open in file viewer" },
71 ...(fileChipMetadata(target)
72 ? [
73 {
74 id: "save",
75 title: "Save or share",
76 },
77 ]
78 : []),
4779 ],
4880 };
4981}
apps/mobile/src/features/threads/useFileChipShare.ts created+70
......@@ -0,0 +1,70 @@
1import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import * as Option from "effect/Option";
4import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
5import { Alert } from "react-native";
6
7import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
8import { assetEnvironment } from "../../state/assets";
9import { usePreparedConnection } from "../../state/session";
10import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
11import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu";
12
13/** Fetches host files through the selected environment before opening the native save/share sheet. */
14export function useFileChipShare(
15 environmentId: EnvironmentId,
16 threadId: ThreadId,
17 sourceIdentifier: string,
18) {
19 const connection = usePreparedConnection(environmentId);
20 const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null;
21 const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
22 refresh: true,
23 reportFailure: false,
24 });
25 const connectionRef = useRef(httpBaseUrl);
26 useLayoutEffect(() => {
27 connectionRef.current = httpBaseUrl;
28 }, [httpBaseUrl]);
29 const requestRef = useRef<AbortController | null>(null);
30 useEffect(() => () => requestRef.current?.abort(), []);
31
32 const share = useCallback(
33 (target: FileChipTarget) => {
34 const source = fileChipShareSource(target, threadId);
35 if (!source || requestRef.current) return;
36 const request = new AbortController();
37 requestRef.current = request;
38 const httpBaseUrl = connectionRef.current;
39 void (async () => {
40 if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again.");
41 const result = await createUrl({ environmentId, input: { resource: source.resource } });
42 if (request.signal.aborted) return;
43 const url =
44 result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null;
45 if (url === null) throw new Error("The file could not be loaded. Reconnect and try again.");
46 await downloadAndShareAttachment({
47 url,
48 attachment: source,
49 signal: request.signal,
50 sourceIdentifier,
51 });
52 })()
53 .catch((error: unknown) => {
54 if (!request.signal.aborted) {
55 Alert.alert(
56 "Could not share file",
57 error instanceof Error ? error.message : "Try again.",
58 );
59 }
60 })
61 .finally(() => {
62 if (requestRef.current === request) {
63 requestRef.current = null;
64 }
65 });
66 },
67 [createUrl, environmentId, sourceIdentifier, threadId],
68 );
69 return share;
70}