Open History to see the last commit
1import { settlePromise } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource";
4import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects";
5import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
6import { useState } from "react";
7import { StackActions, useNavigation } from "@react-navigation/native";
8import { ScrollView, View } from "react-native";
9import { RefreshControl } from "../../components/ui/RefreshControl";
10
11import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
12import { FONT_FAMILIES } from "../../lib/fontFamilies";
13import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences";
14import { useUniwindTheme } from "../../lib/useUniwindTheme";
15import {
16 ThreadMarkdownImage,
17 ThreadMarkdownImageView,
18 ThreadMarkdownImageUnavailable,
19} from "../threads/ThreadMarkdownImage";
20import { ThreadMarkdownVideo } from "../threads/ThreadMarkdownVideo";
21import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
22import {
23 normalizeNativeMarkdownUrl,
24 resolveMarkdownLinkPresentation,
25} from "../../lib/markdownLinks";
26import { AppText as Text } from "../../components/ui/AppText";
27import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal";
28import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
29import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
30import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
31import {
32 SelectableMarkdownText,
33 type MarkdownImageRenderer,
34 type NativeMarkdownTextStyle,
35} from "../../native/SelectableMarkdownText";
36import { fileRoutePathSegments, resolveWorkspaceFilePath } from "./filePath";
37
38interface MarkdownPreviewStyles {
39 readonly nativeTextStyle: NativeMarkdownTextStyle;
40}
41
42export function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
43 const { appearance } = useAppearancePreferences();
44 const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize);
45 const theme = useUniwindTheme();
46 const body = theme["--color-md-body"];
47 const strong = theme["--color-md-strong"];
48 const link = theme["--color-md-link"];
49 const blockquoteBorder = theme["--color-md-blockquote-border"];
50 const codeBackground = theme["--color-md-code-bg"];
51 const codeText = theme["--color-md-code-text"];
52 const horizontalRule = theme["--color-md-hr"];
53 const regularFontFamily = FONT_FAMILIES.regular;
54 const boldFontFamily = FONT_FAMILIES.bold;
55
56 return {
57 nativeTextStyle: {
58 color: body,
59 strongColor: strong,
60 mutedColor: body,
61 linkColor: link,
62 inlineCodeColor: codeText,
63 codeColor: codeText,
64 codeBackgroundColor: codeBackground,
65 codeBlockBackgroundColor: codeBackground,
66 fileTextColor: codeText,
67 quoteMarkerColor: blockquoteBorder,
68 dividerColor: horizontalRule,
69 fontSize: nativeMarkdownTypography.fontSize,
70 lineHeight: nativeMarkdownTypography.lineHeight,
71 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
72 fontFamily: regularFontFamily,
73 headingFontFamily: boldFontFamily,
74 boldFontFamily,
75 },
76 };
77}
78
79export function FileMarkdownPreview(props: {
80 readonly cwd: string;
81 readonly captured?: boolean;
82 readonly environmentId: EnvironmentId;
83 readonly markdown: string;
84 readonly relativePath: string;
85 readonly threadId: ThreadId | null;
86 readonly onRefresh?: () => Promise<void> | void;
87}) {
88 const navigation = useNavigation();
89 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
90 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
91 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
92 const handlePullToRefresh = async () => {
93 if (!props.onRefresh) {
94 return;
95 }
96 setIsPullRefreshing(true);
97 // RefreshControl only needs the spinner cleared; the caller surfaces its own errors.
98 await settlePromise(async () => props.onRefresh?.());
99 setIsPullRefreshing(false);
100 };
101 const markdownDirectory = getBrowseDirectoryPath(
102 resolveWorkspaceFilePath(props.cwd, props.relativePath),
103 );
104 const renderImage: MarkdownImageRenderer = (image) => {
105 const media = resolveMobileMarkdownMediaSource(image.href, {
106 threadId: props.threadId ?? undefined,
107 workspaceRoot: markdownDirectory,
108 imageEmbed: true,
109 });
110 const preview = resolveMarkdownMediaPreview(image.href, {
111 environmentId: props.environmentId,
112 threadId: props.threadId ?? undefined,
113 workspaceRoot: markdownDirectory,
114 captured: props.captured,
115 imageEmbed: true,
116 });
117 const fileLink = resolveMarkdownLinkPresentation(image.href);
118 if (
119 (preview && preview.kind !== "image" && preview.kind !== "video") ||
120 (!props.captured && fileLink.kind === "file" && fileLink.line !== undefined)
121 ) {
122 const name =
123 preview?.source.name ?? (fileLink.kind === "file" ? fileLink.label : "Open file");
124 return (
125 <Text
126 accessibilityRole="link"
127 accessibilityLabel={`Open ${name}`}
128 onPress={() => openMedia(image.href, true)}
129 style={{ color: styles.nativeTextStyle.linkColor }}
130 >
131 {name}
132 </Text>
133 );
134 }
135 if (media?.access === "direct") {
136 if (media.kind === "video") {
137 return (
138 <ThreadMarkdownVideo
139 source={{
140 type: "media",
141 name: media.name,
142 mimeType: media.mimeType,
143 uri: normalizeNativeMarkdownUrl(media.uri),
144 actionsSource: preview?.source.actionsSource,
145 }}
146 thumbnailVisible
147 />
148 );
149 }
150 return (
151 <ThreadMarkdownImageView
152 uri={normalizeNativeMarkdownUrl(media.uri)}
153 sourceKey={media.uri}
154 unavailable={false}
155 alt={image.alt}
156 format={media.mimeType === "image/svg+xml" ? "svg" : undefined}
157 actionsSource={preview?.source.actionsSource}
158 onPressPreview={setExpandedFile}
159 />
160 );
161 }
162 if (props.captured || media === null || media.access === "unavailable") {
163 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
164 }
165 if (media.kind === "video") {
166 return preview?.kind === "video" ? (
167 <ThreadMarkdownVideo source={preview.source} thumbnailVisible />
168 ) : (
169 <ThreadMarkdownImageUnavailable alt={image.alt} />
170 );
171 }
172 return (
173 <ThreadMarkdownImage
174 environmentId={props.environmentId}
175 resource={media.resource}
176 alt={image.alt}
177 srcFragment={media.srcFragment}
178 actionsSource={preview?.source.actionsSource}
179 onPressPreview={setExpandedFile}
180 />
181 );
182 };
183 const styles = useMarkdownPreviewStyles();
184 const resolveImageSource = useMarkdownImageSource({
185 environmentId: props.environmentId,
186 threadId: props.threadId,
187 workspaceRoot: markdownDirectory,
188 captured: props.captured,
189 });
190 const openMedia = (href: string, imageEmbed = false) => {
191 const media = resolveMarkdownMediaPreview(href, {
192 environmentId: props.environmentId,
193 threadId: props.threadId ?? undefined,
194 workspaceRoot: markdownDirectory,
195 captured: props.captured,
196 imageEmbed,
197 });
198 if (!media || media.kind === "file") {
199 const fileLink = resolveMarkdownLinkPresentation(href);
200 if (props.captured || fileLink.kind !== "file") return false;
201 const params = {
202 environmentId: String(props.environmentId),
203 path: fileRoutePathSegments(resolveWorkspaceFilePath(markdownDirectory, fileLink.path)),
204 ...(fileLink.line ? { line: String(fileLink.line) } : {}),
205 };
206 if (props.threadId) {
207 navigation.navigate("ThreadFile", { ...params, threadId: String(props.threadId) });
208 } else {
209 navigation.dispatch(StackActions.push("NewTaskFile", { ...params, cwd: props.cwd }));
210 }
211 return true;
212 }
213 if (media.kind === "video") setExpandedVideo(media.source);
214 else setExpandedFile(media.source);
215 return true;
216 };
217 const onImagePress = (href: string) => {
218 openMedia(href, true);
219 };
220 const onLinkPress = (href: string) => {
221 if (!openMedia(href)) void tryOpenExternalUrl(href, "markdown-link");
222 };
223
224 return (
225 <ScrollView
226 className="flex-1 bg-sheet"
227 contentInsetAdjustmentBehavior="automatic"
228 contentContainerStyle={{ padding: 18 }}
229 refreshControl={
230 props.onRefresh ? (
231 <RefreshControl
232 refreshing={isPullRefreshing}
233 onRefresh={() => void handlePullToRefresh()}
234 />
235 ) : undefined
236 }
237 >
238 <View className="mx-auto w-full max-w-[760px]">
239 <SelectableMarkdownText
240 markdown={maskMarkdownFrontmatter(props.markdown)}
241 fileEmbedLabel={(href) =>
242 markdownFileEmbedLabel(
243 href,
244 {
245 environmentId: props.environmentId,
246 threadId: props.threadId ?? undefined,
247 workspaceRoot: markdownDirectory,
248 captured: props.captured,
249 },
250 false,
251 )
252 }
253 onLinkPress={onLinkPress}
254 renderImage={renderImage}
255 resolveImageSource={resolveImageSource}
256 onImagePress={onImagePress}
257 textStyle={styles.nativeTextStyle}
258 />
259 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
260 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
261 </View>
262 </ScrollView>
263 );
264}