Skip to content
Merged
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
25 changes: 14 additions & 11 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { View } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
import { useThemeColor } from "../lib/useThemeColor";
import { useAssetUrl } from "../state/assets";
import { type AssetRequestSource, useAssetRequestSource } from "../state/assets";

/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
const loadedFaviconUrls = new Set<string>();
Expand All @@ -19,18 +19,20 @@ export function ProjectFavicon(props: {
readonly workspaceRoot?: string | null;
}) {
const size = props.size ?? 42;
const faviconUrl = useAssetUrl(
const faviconSource = useAssetRequestSource(
props.environmentId,
props.workspaceRoot === null || props.workspaceRoot === undefined
? null
: { _tag: "project-favicon", cwd: props.workspaceRoot },
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
const renderableFaviconSource = isProjectFaviconFallbackUrl(faviconSource?.uri ?? null)
? null
: faviconSource;

return (
<ProjectFaviconImage
key={faviconUrl}
faviconUrl={renderableFaviconUrl}
key={faviconSource?.uri}
faviconSource={renderableFaviconSource}
open={props.open}
projectTitle={props.projectTitle}
size={size}
Expand All @@ -39,18 +41,18 @@ export function ProjectFavicon(props: {
}

function ProjectFaviconImage(props: {
readonly faviconUrl: string | null;
readonly faviconSource: AssetRequestSource | null;
readonly open?: boolean;
readonly projectTitle: string;
readonly size: number;
}) {
const iconMuted = useThemeColor("--color-icon-subtle");

const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading",
props.faviconSource && loadedFaviconUrls.has(props.faviconSource.uri) ? "loaded" : "loading",
);

const showImage = props.faviconUrl !== null && status === "loaded";
const showImage = props.faviconSource !== null && status === "loaded";

return (
<View
Expand All @@ -72,10 +74,11 @@ function ProjectFaviconImage(props: {
) : null}

{/* Favicon image (hidden until loaded) */}
{props.faviconUrl ? (
{props.faviconSource ? (
<Image
source={{
uri: props.faviconUrl,
uri: props.faviconSource.uri,
headers: props.faviconSource.headers,
}}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
Expand All @@ -86,7 +89,7 @@ function ProjectFaviconImage(props: {
}}
contentFit="contain"
onLoad={() => {
if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);
if (props.faviconSource) loadedFaviconUrls.add(props.faviconSource.uri);
setStatus("loaded");
}}
onError={() => setStatus("error")}
Expand Down
30 changes: 18 additions & 12 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { SourceFileSurface } from "./SourceFileSurface";
import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import type { AssetRequestSource } from "../../state/assets";
import {
basename,
isBrowserPreviewFile,
Expand Down Expand Up @@ -83,7 +84,7 @@ function defaultViewMode(path: string | null): FileViewMode {

function FileContent(props: {
readonly activeMode: FileViewMode;
readonly previewUri: string | null;
readonly previewSource: AssetRequestSource | null;
readonly fileContents: string | null;
readonly fileError: string | null;
readonly relativePath: string;
Expand All @@ -97,18 +98,18 @@ function FileContent(props: {

if (props.activeMode === "preview" && isImageFile) {
if (isSvgImagePreviewFile(props.relativePath)) {
return <WorkspaceFileWebPreview uri={props.previewUri} />;
return <WorkspaceFileWebPreview source={props.previewSource} />;
}
return (
<WorkspaceFileImagePreview
accessibilityLabel={basename(props.relativePath)}
uri={props.previewUri}
source={props.previewSource}
/>
);
}

if (props.activeMode === "preview" && isBrowserFile) {
return <WorkspaceFileWebPreview uri={props.previewUri} />;
return <WorkspaceFileWebPreview source={props.previewSource} />;
}

if (props.fileError && props.fileContents === null) {
Expand Down Expand Up @@ -482,16 +483,19 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
: defaultViewMode(relativePath);
const resolvedActiveMode = canPreview ? activeMode : "source";
const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;
const assetPreviewUri = useWorkspaceFileAssetUrl({
const assetPreviewSource = useWorkspaceFileAssetUrl({
cwd,
environmentId,
relativePath: assetPreviewPath,
threadId,
});
const previewUri =
assetPreviewUri === null || previewRevision === 0
? assetPreviewUri
: `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
const previewSource =
assetPreviewSource === null || previewRevision === 0
? assetPreviewSource
: {
...assetPreviewSource,
uri: `${assetPreviewSource.uri}${assetPreviewSource.uri.includes("?") ? "&" : "?"}revision=${previewRevision}`,
};
const needsFileContents =
relativePath !== null &&
(resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
Expand Down Expand Up @@ -629,11 +633,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
>
Copy path
</NativeHeaderToolbar.MenuAction>
{isBrowserFile && typeof assetPreviewUri === "string" ? (
{isBrowserFile &&
assetPreviewSource !== null &&
assetPreviewSource.headers === undefined ? (
<NativeHeaderToolbar.MenuAction
icon="safari"
onPress={() => {
void tryOpenExternalUrl(assetPreviewUri, "file-preview");
void tryOpenExternalUrl(assetPreviewSource.uri, "file-preview");
}}
>
Open in Safari
Expand All @@ -653,7 +659,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
</NativeHeaderToolbar>
<FileContent
activeMode={resolvedActiveMode}
previewUri={previewUri}
previewSource={previewSource}
fileContents={fileData?.contents ?? null}
fileError={fileQuery.error}
initialLine={targetLine}
Expand Down
24 changes: 14 additions & 10 deletions apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ import { AsyncResult } from "effect/unstable/reactivity";
import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import { workspaceFileImageAtom } from "./workspace-file-image-cache";
import type { AssetRequestSource } from "../../state/assets";
import { workspaceFileImageSource } from "./workspaceFileImageSource";

function ResolvedWorkspaceFileImagePreview(props: {
readonly accessibilityLabel: string;
readonly uri: string;
readonly source: AssetRequestSource;
}) {
const [loadError, setLoadError] = useState<string | null>(null);
const [fullScreenVisible, setFullScreenVisible] = useState(false);
const imageSource = useMemo(
() => ({ uri: props.uri, cache: "force-cache" as const }),
[props.uri],
);
const imageSource = useMemo(() => workspaceFileImageSource(props.source), [props.source]);
const fullScreenImages = useMemo(() => [imageSource], [imageSource]);

return (
Expand Down Expand Up @@ -89,16 +88,16 @@ function CachedWorkspaceFileImagePreview(props: {
return (
<ResolvedWorkspaceFileImagePreview
accessibilityLabel={props.accessibilityLabel}
uri={imageResult.value}
source={{ uri: imageResult.value }}
/>
);
}

export function WorkspaceFileImagePreview(props: {
readonly accessibilityLabel: string;
readonly uri: string | null;
readonly source: AssetRequestSource | null;
}) {
if (props.uri === null) {
if (props.source === null) {
return (
<View className="flex-1 items-center justify-center gap-3 bg-card px-6">
<ActivityIndicator />
Expand All @@ -109,10 +108,15 @@ export function WorkspaceFileImagePreview(props: {
);
}

return (
return props.source.headers === undefined ? (
<CachedWorkspaceFileImagePreview
accessibilityLabel={props.accessibilityLabel}
uri={props.uri}
uri={props.source.uri}
/>
) : (
<ResolvedWorkspaceFileImagePreview
accessibilityLabel={props.accessibilityLabel}
source={props.source}
/>
);
}
23 changes: 19 additions & 4 deletions apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { WebView } from "react-native-webview";

import { AppText as Text } from "../../components/AppText";
import { LoadingStrip } from "../../components/LoadingStrip";
import type { AssetRequestSource } from "../../state/assets";

export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) {
export function WorkspaceFileWebPreview(props: { readonly source: AssetRequestSource | null }) {
const [loadProgress, setLoadProgress] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const webViewSource = useMemo(() => {
if (props.source === null) return null;
if (props.source.surfaceBinding === undefined) return { uri: props.source.uri };
const assetUrl = new URL(props.source.uri);
return {
uri: props.source.surfaceBinding.uri,
method: "POST" as const,
headers: { "content-type": "application/json" },
body: JSON.stringify({
credential: props.source.surfaceBinding.credential,
redirect: `${assetUrl.pathname}${assetUrl.search}`,
}),
};
}, [props.source]);

if (props.uri === null) {
if (webViewSource === null) {
return (
<View className="flex-1 items-center justify-center gap-3 bg-card px-6">
<ActivityIndicator />
Expand All @@ -28,7 +43,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null })
</View>
) : null}
<WebView
source={{ uri: props.uri }}
source={webViewSource}
originWhitelist={["*"]}
allowsBackForwardNavigationGestures
allowsFullscreenVideo
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/files/workspaceFileAssetUrl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useMemo } from "react";

import { useAssetUrl } from "../../state/assets";
import { useAssetRequestSource } from "../../state/assets";
import { resolveWorkspaceFilePath } from "./filePath";

export function useWorkspaceFileAssetUrl(props: {
Expand All @@ -18,7 +18,7 @@ export function useWorkspaceFileAssetUrl(props: {
[props.cwd, props.relativePath],
);

return useAssetUrl(
return useAssetRequestSource(
props.environmentId,
absolutePath !== null && props.threadId !== null
? {
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/features/files/workspaceFileImageSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vite-plus/test";

import { workspaceFileImageSource } from "./workspaceFileImageSource";

describe("workspaceFileImageSource", () => {
it("force-caches only local cached-file sources", () => {
expect(workspaceFileImageSource({ uri: "file:///cache/image.png" })).toEqual({
uri: "file:///cache/image.png",
headers: undefined,
cache: "force-cache",
});
});

it("reloads session-bound relay sources instead of force-caching them", () => {
expect(
workspaceFileImageSource({
uri: "https://environment.example/api/assets/relay/token/image.png",
headers: { "x-t3-asset-surface": "surface.credential" },
}),
).toEqual({
uri: "https://environment.example/api/assets/relay/token/image.png",
headers: { "x-t3-asset-surface": "surface.credential" },
cache: "reload",
});
});
});
11 changes: 11 additions & 0 deletions apps/mobile/src/features/files/workspaceFileImageSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { AssetRequestSource } from "../../state/assets";

export function workspaceFileImageSource(source: AssetRequestSource) {
return {
uri: source.uri,
headers: source.headers,
// Relay-backed images are session-bound and served with no-store. Revalidate them instead of
// allowing React Native to reuse a surface credential after it has expired or been revoked.
cache: source.headers === undefined ? ("force-cache" as const) : ("reload" as const),
};
}
17 changes: 12 additions & 5 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import {
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import { useAssetUrl } from "../../state/assets";
import { useAssetRequestSource } from "../../state/assets";
import { resolveWorkspaceRelativeFilePath } from "../files/filePath";

const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
Expand Down Expand Up @@ -147,12 +147,12 @@ function MessageAttachmentImage(props: {
readonly className: string;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
}) {
const uri = useAssetUrl(props.environmentId, {
const source = useAssetRequestSource(props.environmentId, {
_tag: "attachment",
attachmentId: props.attachmentId,
});

if (uri === null) {
if (source === null) {
return (
<View className={`${props.className} items-center justify-center`}>
<ActivityIndicator />
Expand All @@ -161,8 +161,15 @@ function MessageAttachmentImage(props: {
}

return (
<TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>
<Image source={{ uri }} className={props.className} resizeMode="cover" />
<TouchableOpacity
activeOpacity={0.7}
onPress={() => props.onPressImage(source.uri, source.headers)}
>
<Image
source={{ uri: source.uri, headers: source.headers }}
className={props.className}
resizeMode="cover"
/>
</TouchableOpacity>
);
}
Expand Down
Loading
Loading