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