Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
},
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/files/AttachmentFileScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ type AttachmentFileScreenProps = StaticScreenProps<AttachmentFileRouteParams>;
function nativeViewerKind(kind: ReturnType<typeof useAttachmentDocument>["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;
}

Expand Down
51 changes: 45 additions & 6 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -227,6 +230,7 @@ function FileContent(props: {
readonly initialLine: number | null;
readonly truncated: boolean;
readonly onRefresh?: () => Promise<void> | void;
readonly onDocumentClose: () => void;
}) {
// Reopening a mutable host file must not reuse a poster from an earlier visit.
const thumbnailInstanceId = useId();
Expand All @@ -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));
Expand All @@ -267,6 +273,14 @@ function FileContent(props: {
);
}

if (officeKind !== null) {
return props.previewUri === null || props.mediaSource === undefined ? (
<FilePreviewLoading message="Loading document..." />
) : (
<WorkspaceOfficeDocument source={props.mediaSource} onClose={props.onDocumentClose} />
);
}

if (isAudioFile) {
return props.previewUri === null ? (
<FilePreviewLoading message="Loading file..." />
Expand Down Expand Up @@ -608,6 +622,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(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 =
Expand All @@ -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,
Expand All @@ -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<MediaVideoPreviewSource | null>(
Expand Down Expand Up @@ -685,6 +712,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
};
const needsFileContents =
relativePath !== null &&
officeKind === null &&
!isVideoFile &&
!isAudioFile &&
(resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -885,6 +922,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
resolvedActiveMode,
mediaSource,
mediaActions.actions,
officeKind,
fileData?.contents,
fileData?.truncated,
]);
Expand Down Expand Up @@ -968,6 +1006,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
threadId={threadId}
truncated={fileData?.truncated ?? false}
onRefresh={() => fileQuery.refresh()}
onDocumentClose={handleBack}
/>
</MaterialScreenContent>
<FilePreviewModal
Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/src/features/files/WorkspaceOfficeDocument.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { WorkspaceOfficeDocument } from "./WorkspaceOfficeDocument";
import { FilePreviewModal } from "../../components/FilePreviewModal";

vi.mock("react-native", () => ({ View: ({ children }: { children: unknown }) => children }));
vi.mock("../../components/EmptyState", () => ({
EmptyState: ({ title, detail }: { title: string; detail: string }) => (
<div>
{title}: {detail}
</div>
),
}));
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(<WorkspaceOfficeDocument source={source} onClose={onClose} />);
});
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(<WorkspaceOfficeDocument source={source} onClose={onClose} />);
});
await act(() => renderer!.root.findByType(FilePreviewModal).props.onRequestClose());
expect(onClose).toHaveBeenCalledOnce();
});
39 changes: 39 additions & 0 deletions apps/mobile/src/features/files/WorkspaceOfficeDocument.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const pendingError = useRef<string | null>(null);
return (
<View className="flex-1 items-center justify-center px-6">
<EmptyState
title={error ? "No compatible document viewer" : "Opening document"}
detail={error ?? "Use Save or share in File actions to open the original in another app."}
/>
<FilePreviewModal
source={open ? { ...props.source, kind: "document" } : null}
onOpenError={(cause: unknown) => {
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();
}}
/>
</View>
);
}
2 changes: 2 additions & 0 deletions apps/mobile/src/features/files/preload-workspace-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
import {
isWorkspaceBrowserPreviewPath,
isWorkspaceImagePreviewPath,
officeFilePreviewKind,
} from "@t3tools/shared/filePreview";

import { appAtomRegistry } from "../../state/atom-registry";
Expand Down Expand Up @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading