diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 16eccc711442..b367c713433f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -144,8 +144,10 @@ "@types/mdast": "^4.0.4", "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", + "@types/react-test-renderer": "19.1.0", "babel-preset-expo": "~58.0.8", "jsdom": "^30.0.1", + "react-test-renderer": "19.3.0", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index e877b165eb9f..366e088da514 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -49,7 +49,8 @@ type AttachmentFileScreenProps = StaticScreenProps; function nativeViewerKind(kind: ReturnType["kind"]) { if (kind === "image") return "image" as const; if (kind === "pdf") return "pdf" as const; - if (kind === "video" || kind === "unsupported") return "document" as const; + if (kind === "video" || kind === "unsupported" || kind === "docx" || kind === "xlsx") + return "document" as const; return null; } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca9adeaea165..16d3a48c0f49 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -12,6 +12,8 @@ import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, mediaMimeTypeFromExtension, + officeFilePreviewKind, + OFFICE_PREVIEW_MIME_TYPES, } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; @@ -55,6 +57,7 @@ import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import { WorkspaceOfficeDocument } from "./WorkspaceOfficeDocument"; import { basename, fileHeaderSubtitle, @@ -227,6 +230,7 @@ function FileContent(props: { readonly initialLine: number | null; readonly truncated: boolean; readonly onRefresh?: () => Promise | void; + readonly onDocumentClose: () => void; }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); @@ -239,8 +243,10 @@ function FileContent(props: { const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); const isVideoFile = isVideoPreviewFile(props.relativePath); const isAudioFile = isAudioPreviewFile(props.relativePath); + const officeKind = officeFilePreviewKind(props.relativePath); // Only the surfaces that wait on a signed asset URL can be blocked by one. const needsAssetUrl = + officeKind !== null || isVideoFile || isAudioFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile)); @@ -267,6 +273,14 @@ function FileContent(props: { ); } + if (officeKind !== null) { + return props.previewUri === null || props.mediaSource === undefined ? ( + + ) : ( + + ); + } + if (isAudioFile) { return props.previewUri === null ? ( @@ -608,6 +622,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const [fullScreenPreview, setFullScreenPreview] = useState(null); const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); const isAudioFile = relativePath !== null && !isVideoFile && isAudioPreviewFile(relativePath); + const officeKind = relativePath === null ? null : officeFilePreviewKind(relativePath); const isBrowserFile = relativePath !== null && !isVideoFile && isWorkspaceBrowserPreviewPath(relativePath); const isImageFile = @@ -626,7 +641,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const resolvedActiveMode = isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source"; const assetPreviewPath = - isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null; + isBrowserFile || isImageFile || isVideoFile || isAudioFile || officeKind !== null + ? relativePath + : null; const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, @@ -643,19 +660,29 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { assetPreview.resource !== null && "path" in assetPreview.resource && typeof assetPreview.resource.path === "string" && - (isImageFile || isVideoFile) + (isImageFile || isVideoFile || officeKind !== null) ? { reference: mediaFileReference(assetPreview.resource.path, cwd), name: basename(relativePath), mimeType: - mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? - "application/octet-stream", + (officeKind === null + ? mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) + : OFFICE_PREVIEW_MIME_TYPES[officeKind]) ?? "application/octet-stream", environmentId, ...(threadId === null ? {} : { threadId }), resource: assetPreview.resource, } : undefined, - [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], + [ + assetPreview.resource, + cwd, + environmentId, + isImageFile, + isVideoFile, + officeKind, + relativePath, + threadId, + ], ); const mediaActions = useMediaActions(mediaSource); const videoSource = useMemo( @@ -685,6 +712,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { }; const needsFileContents = relativePath !== null && + officeKind === null && !isVideoFile && !isAudioFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); @@ -793,7 +821,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } as const) : null, // Only the source body wraps; a rendered preview lays itself out. - resolvedActiveMode === "source" + resolvedActiveMode === "source" && officeKind === null ? ({ id: "word-wrap", title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", @@ -847,6 +875,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { }), } as const) : null, + officeKind !== null && mediaSource !== undefined + ? ({ + id: "open-document", + title: "Open in file viewer", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => setFullScreenPreview({ ...mediaSource, kind: "document" }), + } as const) + : null, isBrowserFile && typeof assetPreviewUri === "string" ? ({ id: "open-browser", @@ -885,6 +922,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { resolvedActiveMode, mediaSource, mediaActions.actions, + officeKind, fileData?.contents, fileData?.truncated, ]); @@ -968,6 +1006,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { threadId={threadId} truncated={fileData?.truncated ?? false} onRefresh={() => fileQuery.refresh()} + onDocumentClose={handleBack} /> ({ View: ({ children }: { children: unknown }) => children })); +vi.mock("../../components/EmptyState", () => ({ + EmptyState: ({ title, detail }: { title: string; detail: string }) => ( +
+ {title}: {detail} +
+ ), +})); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +let renderer: ReactTestRenderer | undefined; +beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); +afterEach(async () => { + if (renderer) await act(() => renderer!.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); +const source = { + name: "report.xlsx", + mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + environmentId: EnvironmentId.make("test"), + resource: { + _tag: "workspace-file" as const, + threadId: ThreadId.make("thread"), + path: "/workspace/report.xlsx", + }, +}; +it("keeps a useful save/share fallback when the device cannot open Office files", async () => { + const onClose = vi.fn(); + await act(() => { + renderer = create(); + }); + await act(() => { + const modal = renderer!.root.findByType(FilePreviewModal); + modal.props.onOpenError(new Error("Install a compatible viewer or use Save or share.")); + modal.props.onRequestClose(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(JSON.stringify(renderer!.toJSON())).toContain("No compatible document viewer"); + expect(JSON.stringify(renderer!.toJSON())).toContain("Save or share"); +}); +it("returns from the file screen after a successful native visit", async () => { + const onClose = vi.fn(); + await act(() => { + renderer = create(); + }); + await act(() => renderer!.root.findByType(FilePreviewModal).props.onRequestClose()); + expect(onClose).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/features/files/WorkspaceOfficeDocument.tsx b/apps/mobile/src/features/files/WorkspaceOfficeDocument.tsx new file mode 100644 index 000000000000..7a9e8338d254 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceOfficeDocument.tsx @@ -0,0 +1,39 @@ +import { useRef, useState } from "react"; +import { View } from "react-native"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { EmptyState } from "../../components/EmptyState"; +import type { MediaActionsSource } from "../../lib/mediaActionsSource"; + +/** Workspace Office files share the same native viewer and save/share fallback as attachments. */ +export function WorkspaceOfficeDocument(props: { + readonly source: MediaActionsSource; + readonly onClose: () => void; +}) { + const [open, setOpen] = useState(true); + const [error, setError] = useState(null); + const pendingError = useRef(null); + return ( + + + { + pendingError.current = + cause instanceof Error + ? cause.message + : "This device could not open the document. Use Save or share in File actions."; + }} + onRequestClose={() => { + setOpen(false); + if (pendingError.current) { + setError(pendingError.current); + pendingError.current = null; + } else props.onClose(); + }} + /> + + ); +} diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index 4ae4f507caa0..5442d6d84296 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -5,6 +5,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, + officeFilePreviewKind, } from "@t3tools/shared/filePreview"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -35,6 +36,7 @@ export function preloadWorkspaceFileContents(input: { !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) || isWorkspaceBrowserPreviewPath(input.relativePath) || isWorkspaceImagePreviewPath(input.relativePath) || + officeFilePreviewKind(input.relativePath) !== null || isVideoPreviewFile(input.relativePath) ) { return; diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e32f41372528..eca709506444 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -293,6 +293,46 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("versions Office files without reading contents and streams exact-file downloads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-office-version-" }); + const filePath = path.join(root, "report.xlsx"); + yield* fs.writeFileString(filePath, "first"); + const resource = { + _tag: "workspace-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }; + const first = yield* issueAssetUrl({ resource, workspaceRoot: root }); + const unchanged = yield* issueAssetUrl({ resource, workspaceRoot: root }); + expect(first.byteLength).toBe(5); + expect(first.fileVersion).toBeDefined(); + expect(unchanged.fileVersion).toBe(first.fileVersion); + yield* fs.writeFileString(filePath, "other"); + const changed = yield* issueAssetUrl({ resource, workspaceRoot: root }); + expect(changed.byteLength).toBe(5); + expect(changed.fileVersion).not.toBe(first.fileVersion); + const download = yield* issueAssetUrl({ + resource: { ...resource, disposition: "attachment" }, + workspaceRoot: root, + }); + const suffix = download.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ download: true, fileName: "report.xlsx" }); + expect(yield* resolveAsset(suffix.slice(0, separator), "sibling.xlsx")).toBeNull(); + const outside = yield* issueAssetUrl({ + resource: { ...resource, _tag: "media-file" }, + workspaceRoot: root, + }); + expect(outside.fileVersion).toBe(changed.fileVersion); + expect(outside.byteLength).toBe(5); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("reports pixel dimensions from an image header and nothing for other files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -919,6 +959,46 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect( + "serves Office workspace and host files through exact signed URLs without exposing siblings", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-office-preview-" }); + const bytes = Uint8Array.from([0x50, 0x4b, 0, 255, 1]); + for (const [extension, mimeType] of [ + ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ]) { + const name = `report#final.${extension}`; + const filePath = path.join(root, name); + yield* fs.writeFile(filePath, bytes); + yield* fs.writeFileString(path.join(root, `sibling.${extension}`), "private sibling"); + for (const resource of [ + { + _tag: "workspace-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + { _tag: "media-file" as const, threadId: ThreadId.make("thread-1"), path: filePath }, + { _tag: "draft-workspace-file" as const, cwd: root, path: filePath }, + ]) { + const issued = yield* issueAssetUrl({ resource, workspaceRoot: root }); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const resolved = yield* resolveAsset(token, suffix.slice(separator + 1)); + expect(resolved).toMatchObject({ kind: "file", mimeType }); + expect(Array.from(yield* fs.readFile(filePath))).toEqual(Array.from(bytes)); + expect(yield* resolveAsset(token, `sibling.${extension}`)).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + } + }).pipe(Effect.provide(layerTest)), + ); + it.effect("previews workspace files with literal fragment characters in their paths", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 6b736c68f74e..1e51a55923f1 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -20,6 +20,8 @@ import { import { audioMimeTypeFromExtension, hostPreviewMimeTypeFromExtension, + officeFilePreviewKind, + OFFICE_PREVIEW_MIME_TYPES, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, @@ -107,6 +109,7 @@ const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file-exact"), + download: Schema.optionalKey(Schema.Boolean), workspaceRoot: Schema.String, relativePath: Schema.String, expiresAt: Schema.Number, @@ -114,6 +117,7 @@ const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("media-file-exact"), + download: Schema.optionalKey(Schema.Boolean), filePath: Schema.String, device: Schema.String, inode: Schema.String, @@ -363,6 +367,17 @@ const readImageDimensionsFromHeader = (filePath: string) => Effect.orElseSucceed((): ImageDimensions | null => null), ); +const officeFileMetadata = (file: OpenMediaFile) => ({ + fileVersion: [ + file.info.dev, + file.info.ino, + file.info.size, + file.info.mtimeNs, + file.info.ctimeNs, + ].join(":"), + byteLength: Number(file.info.size), +}); + const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")( function* (input: { readonly requestedPath: string; @@ -393,6 +408,8 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe (dimensions) => ({ identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, dimensions, + fileMetadata: + officeFilePreviewKind(canonicalFile) === null ? null : officeFileMetadata(file), }), ), ), @@ -414,6 +431,7 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe }, fileName: path.basename(canonicalFile), imageDimensions: opened.dimensions, + fileMetadata: opened.fileMetadata, }; }, ); @@ -478,24 +496,39 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA ) ? yield* readImageDimensionsFromHeader(canonicalFile) : null; + const fileMetadata = + officeFilePreviewKind(resolved.relativePath) === null + ? null + : yield* openMediaFile(canonicalFile).pipe( + Effect.map((file) => (file === null ? null : officeFileMetadata(file))), + Effect.scoped, + Effect.mapError( + (cause) => + new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); return { - claims: isWorkspaceImagePreviewPath(resolved.relativePath) - ? { - version: 1 as const, - kind: "workspace-file-exact" as const, - workspaceRoot: canonicalWorkspaceRoot, - relativePath: resolved.relativePath, - expiresAt: input.expiresAt, - } - : { - version: 1 as const, - kind: "workspace-file" as const, - workspaceRoot: canonicalWorkspaceRoot, - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt: input.expiresAt, - }, + claims: + isWorkspaceImagePreviewPath(resolved.relativePath) || + officeFilePreviewKind(resolved.relativePath) !== null || + ("disposition" in input.resource && input.resource.disposition === "attachment") + ? { + version: 1 as const, + kind: "workspace-file-exact" as const, + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt: input.expiresAt, + } + : { + version: 1 as const, + kind: "workspace-file" as const, + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt: input.expiresAt, + }, fileName: path.basename(resolved.relativePath), imageDimensions, + fileMetadata, }; }, ); @@ -515,6 +548,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let fileName: string; let sourcePath: string | undefined; let imageDimensions: ImageDimensions | null = null; + let fileMetadata: ReturnType | null = null; switch (input.resource._tag) { case "media-file": { @@ -541,6 +575,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i claims = finalized.claims; fileName = finalized.fileName; imageDimensions = finalized.imageDimensions; + fileMetadata = finalized.fileMetadata; break; } case "workspace-file": { @@ -567,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i claims = finalized.claims; fileName = finalized.fileName; imageDimensions = finalized.imageDimensions; + fileMetadata = finalized.fileMetadata; break; } case "draft-workspace-file": { @@ -603,6 +639,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i claims = finalized.claims; fileName = finalized.fileName; imageDimensions = finalized.imageDimensions; + fileMetadata = finalized.fileMetadata; break; } case "attachment": { @@ -825,6 +862,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i PROJECT_FAVICON_TOKEN_BUCKET_MS; claims = { ...claims, expiresAt }; } + if ( + (claims.kind === "workspace-file-exact" || claims.kind === "media-file-exact") && + "disposition" in input.resource && + input.resource.disposition === "attachment" + ) + claims = { ...claims, download: true }; const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { @@ -832,6 +875,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), ...(imageDimensions !== null ? { imageDimensions } : {}), + ...fileMetadata, }; }); @@ -958,7 +1002,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => null), ); return file - ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: canonicalFile, + mimeType, + file, + ...(claims.download ? { download: true, fileName: path.basename(claims.filePath) } : {}), + } satisfies ResolvedAsset) : null; } if (claims.kind === "workspace-file-exact") { @@ -967,8 +1017,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); + const officeKind = + exactWorkspaceFile === null ? null : officeFilePreviewKind(exactWorkspaceFile); return exactWorkspaceFile - ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: exactWorkspaceFile, + ...(claims.download + ? { download: true, fileName: path.basename(claims.relativePath) } + : {}), + ...(officeKind === null ? {} : { mimeType: OFFICE_PREVIEW_MIME_TYPES[officeKind] }), + } satisfies ResolvedAsset) : null; } const segments = decodedPath.split(/[\\/]/); diff --git a/apps/web/package.json b/apps/web/package.json index b4ef7684a006..2a8e0977b67c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,8 +52,10 @@ "@tiptap/starter-kit": "^3.31.3", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", + "docx-preview": "0.4.1", "dompurify": "^3.4.16", "effect": "catalog:", + "fflate": "0.8.3", "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", @@ -68,8 +70,10 @@ "react-dom": "19.2.6", "react-markdown": "^10.1.0", "remark-gfm": "catalog:", + "saxes": "6.0.0", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index e401135d21d5..967edf0b1a9a 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -44,6 +44,15 @@ export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, ): () => Promise { + const refreshAsset = useAssetRefresh(environmentId, resource); + return useCallback(async () => (await refreshAsset())?.url ?? null, [refreshAsset]); +} + +/** Reauthorization returns metadata too, without downloading the mutable file. */ +export function useAssetRefresh( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +) { const connection = usePreparedConnection(environmentId); const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -54,7 +63,8 @@ export function useAssetUrlRefresh( if (environmentId === null || resource === null || httpBaseUrl === null) return null; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); - return resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + return url === null ? null : { ...result.value, url }; }, [environmentId, resource, refresh, httpBaseUrl]); } diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 31d80c5c8210..be894ea517d3 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -19,6 +19,8 @@ import { cn } from "~/lib/utils"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; +import { OfficeFilePreview } from "./OfficeFilePreview"; +import { downloadFile } from "./downloadFile"; import { FILE_SURFACE_SUBHEADER_CLASS, FileSurfaceAction, @@ -197,9 +199,8 @@ export function AttachmentFilePreview(props: { if (!file) { const target = await prepareDownload(); if (!target) throw new Error("Reconnect to the environment and try again."); - const response = await fetch(target); - if (!response.ok) throw new Error("The file could not be loaded. Try again."); - file = await response.blob(); + await downloadFile(target, props.name); + return; } // A Blob keeps cross-origin downloads inside the desktop client instead of // navigating its custom app scheme to an external browser. @@ -246,6 +247,15 @@ export function AttachmentFilePreview(props: { ) : ( ) + ) : kind === "docx" || kind === "xlsx" ? ( + setRevision((value) => value + 1)} + /> ) : kind === "pdf" || kind === "html" ? ( void; +}) { + const [result, setResult] = useState<{ html: string | null; error: string | null }>({ + html: null, + error: null, + }); + useEffect(() => { + const controller = new AbortController(); + void renderDocxDocument(props.bytes, controller.signal) + .then((html) => { + if (!controller.signal.aborted) setResult({ html, error: null }); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setResult({ + html: null, + error: cause instanceof Error ? cause.message : "Could not render this Word document.", + }); + }); + return () => { + controller.abort(); + }; + }, [props.bytes]); + if (result.error !== null) + return ; + if (result.html === null) return ; + return ( +