| author | |
| committer | |
| log | a495385584276d0e568df23646a49ce8b40a4707 |
| tree | 8c1438cd8a1ce9fc098d635b8706c51cf31943ed |
| parent | b7465a3bc993e7f10f4ec7a469759b95eb4c2f2a |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 163 insertions(+), 9 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+15-6| ... | @@ -178,6 +178,7 @@ import { | ... | @@ -178,6 +178,7 @@ import { |
| 178 | resolveWorkspaceRelativeFilePath, | 178 | resolveWorkspaceRelativeFilePath, |
| 179 | } from "../files/filePath"; | 179 | } from "../files/filePath"; |
| 180 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; | 180 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; |
| 181 | import { useFileChipShare } from "./useFileChipShare"; | ||
| 181 | import { | 182 | import { |
| 182 | MarkdownImageAvailableWidthContext, | 183 | MarkdownImageAvailableWidthContext, |
| 183 | ThreadMarkdownImage, | 184 | ThreadMarkdownImage, |
| ... | @@ -1964,6 +1965,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1964,6 +1965,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1964 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; | 1965 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; |
| 1965 | const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); | 1966 | const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); |
| 1966 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); | 1967 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); |
| 1968 | const fileShareSourceIdentifier = useId(); | ||
| 1969 | const shareFileChip = useFileChipShare( | ||
| 1970 | props.environmentId, | ||
| 1971 | props.threadId, | ||
| 1972 | fileShareSourceIdentifier, | ||
| 1973 | ); | ||
| 1967 | useEffect(() => { | 1974 | useEffect(() => { |
| 1968 | setExpandedVideo(null); | 1975 | setExpandedVideo(null); |
| 1969 | setExpandedFile(null); | 1976 | setExpandedFile(null); |
| ... | @@ -2116,10 +2123,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2116,10 +2123,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2116 | case "open-file": | 2123 | case "open-file": |
| 2117 | onMarkdownLinkPress(href); | 2124 | onMarkdownLinkPress(href); |
| 2118 | return; | 2125 | return; |
| 2126 | case "save": | ||
| 2127 | shareFileChip(target); | ||
| 2128 | return; | ||
| 2119 | } | 2129 | } |
| 2120 | }, | 2130 | }, |
| 2121 | }), | 2131 | }), |
| 2122 | [onMarkdownLinkPress, props.workspaceRoot], | 2132 | [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], |
| 2123 | ); | 2133 | ); |
| 2124 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( | 2134 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 2125 | (image) => { | 2135 | (image) => { |
| ... | @@ -2710,7 +2720,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2710,7 +2720,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2710 | } | 2720 | } |
| 2711 | 2721 | ||
| 2712 | return ( | 2722 | return ( |
| 2713 | <> | 2723 | <PresentationSource identifier={fileShareSourceIdentifier} style={{ flex: 1 }}> |
| 2714 | <View className="flex-1" onLayout={handleViewportLayout}> | 2724 | <View className="flex-1" onLayout={handleViewportLayout}> |
| 2715 | <View className="flex-1"> | 2725 | <View className="flex-1"> |
| 2716 | <KeyboardAwareLegendList | 2726 | <KeyboardAwareLegendList |
| ... | @@ -2869,10 +2879,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2869,10 +2879,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2869 | /> | 2879 | /> |
| 2870 | </View> | 2880 | </View> |
| 2871 | ) : null} | 2881 | ) : null} |
| 2882 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> | ||
| 2883 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> | ||
| 2872 | </View> | 2884 | </View> |
| 2873 | 2885 | </PresentationSource> | |
| 2874 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> | ||
| 2875 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> | ||
| 2876 | </> | ||
| 2877 | ); | 2886 | ); |
| 2878 | }); | 2887 | }); |
apps/mobile/src/features/threads/fileChipMenu.test.ts+44-1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; | 4 | import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu"; |
| 4 | 5 | ||
| 5 | describe("resolveFileChipTarget", () => { | 6 | describe("resolveFileChipTarget", () => { |
| 6 | it("resolves a workspace-relative link to both paths", () => { | 7 | it("resolves a workspace-relative link to both paths", () => { |
| ... | @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { | ... | @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { |
| 42 | ]); | 43 | ]); |
| 43 | }); | 44 | }); |
| 44 | }); | 45 | }); |
| 46 | |||
| 47 | describe("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 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | import { fileBasename } from "@t3tools/client-runtime/markdown-links"; | ||
| 2 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 2 | import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; | 4 | import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; |
| 5 | import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | ||
| 3 | 6 | ||
| 4 | import { | 7 | import { |
| 5 | isAbsolutePath, | 8 | isAbsolutePath, |
| ... | @@ -7,7 +10,7 @@ import { | ... | @@ -7,7 +10,7 @@ import { |
| 7 | resolveWorkspaceRelativeFilePath, | 10 | resolveWorkspaceRelativeFilePath, |
| 8 | } from "../files/filePath"; | 11 | } from "../files/filePath"; |
| 9 | 12 | ||
| 10 | export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; | 13 | export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; |
| 11 | 14 | ||
| 12 | export interface FileChipTarget { | 15 | export interface FileChipTarget { |
| 13 | /** The host path, when the link is absolute or the workspace root is known. */ | 16 | /** The host path, when the link is absolute or the workspace root is known. */ |
| ... | @@ -36,7 +39,28 @@ export function resolveFileChipTarget( | ... | @@ -36,7 +39,28 @@ export function resolveFileChipTarget( |
| 36 | }; | 39 | }; |
| 37 | } | 40 | } |
| 38 | 41 | ||
| 39 | /** The same actions the web file chip offers on right-click. Opening is what a tap does. */ | 42 | function 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. */ | ||
| 52 | export 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. */ | ||
| 40 | export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { | 64 | export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { |
| 41 | return { | 65 | return { |
| 42 | title: target.fullPath ?? target.relativePath ?? "", | 66 | title: target.fullPath ?? target.relativePath ?? "", |
| ... | @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { | ... | @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { |
| 44 | ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), | 68 | ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), |
| 45 | ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), | 69 | ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), |
| 46 | { id: "open-file", title: "Open in file viewer" }, | 70 | { id: "open-file", title: "Open in file viewer" }, |
| 71 | ...(fileChipMetadata(target) | ||
| 72 | ? [ | ||
| 73 | { | ||
| 74 | id: "save", | ||
| 75 | title: "Save or share", | ||
| 76 | }, | ||
| 77 | ] | ||
| 78 | : []), | ||
| 47 | ], | 79 | ], |
| 48 | }; | 80 | }; |
| 49 | } | 81 | } |
apps/mobile/src/features/threads/useFileChipShare.ts created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; | ||
| 5 | import { Alert } from "react-native"; | ||
| 6 | |||
| 7 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; | ||
| 8 | import { assetEnvironment } from "../../state/assets"; | ||
| 9 | import { usePreparedConnection } from "../../state/session"; | ||
| 10 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | ||
| 11 | import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu"; | ||
| 12 | |||
| 13 | /** Fetches host files through the selected environment before opening the native save/share sheet. */ | ||
| 14 | export 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 | } | ||