1import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3import { Image } from "expo-image";
4import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";
5import { fetch } from "expo/fetch";
6import { parse, SvgAst, type JsxAST } from "react-native-svg";
7import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from "react-native";
8
9import { AppActivityIndicator } from "../../components/AppActivityIndicator";
10import { useFilePreviewGallery } from "../../components/FilePreviewGallery";
11import { AppText as Text } from "../../components/AppText";
12import type { FilePreviewSource } from "../../components/FilePreviewModal";
13import { MediaActionsMenu } from "../../components/MediaActionsMenu";
14import { PresentationSource } from "../../components/NativePresentation";
15import { useMediaActions } from "../../state/mediaActions";
16import type { MediaActionsSource } from "../../lib/mediaActionsSource";
17import { useAssetUrlState } from "../../state/assets";
18import {
19 MARKDOWN_IMAGE_MAX_WIDTH,
20 type MarkdownImageDisplaySize,
21 resolveMarkdownImageDisplaySize,
22 resolveSvgImageSize,
23} from "./markdownImageSize";
24
25/**
26 * Width the feed lays markdown out in. The feed already knows this from its
27 * viewport, so an image can size its frame on the first render instead of
28 * waiting for its own onLayout, which would change the row's height once
29 * more after the list has positioned the rows below it. It is an upper
30 * bound: a list item or blockquote indents its column, and the measured
31 * width takes over once it is known.
32 */
33export const MarkdownImageAvailableWidthContext = createContext(0);
34
35export function ThreadMarkdownImageView(props: {
36 readonly uri: string | null;
37 readonly sourceKey: string;
38 readonly unavailable: boolean;
39 /** Why the image is unavailable, when the cause is known. */
40 readonly unavailableDetail?: string | undefined;
41 readonly alt: string | null;
42 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */
43 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
44 readonly format?: "svg";
45 readonly actionsSource?: MediaActionsSource;
46 readonly onPressPreview: (source: FilePreviewSource) => void;
47}) {
48 const withGallery = useFilePreviewGallery();
49 const sourceIdentifier = useId();
50 const mediaActions = useMediaActions(props.actionsSource);
51 const contextWidth = useContext(MarkdownImageAvailableWidthContext);
52 const [measuredWidth, setMeasuredWidth] = useState(0);
53 const availableWidth =
54 measuredWidth > 0 && contextWidth > 0
55 ? Math.min(contextWidth, measuredWidth)
56 : contextWidth || measuredWidth;
57 const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null);
58 const [failedUri, setFailedUri] = useState<string | null>(null);
59
60 useEffect(() => {
61 setDecodedSize(null);
62 }, [props.sourceKey]);
63
64 useEffect(() => {
65 setFailedUri(null);
66 }, [props.uri]);
67
68 // The decoded size is what the platform actually drew, so it wins over the
69 // server's header hint once it exists.
70 const sourceSize = decodedSize ?? props.knownSize ?? null;
71 const displaySize: MarkdownImageDisplaySize | null =
72 sourceSize === null || availableWidth <= 0
73 ? null
74 : resolveMarkdownImageDisplaySize({
75 sourceWidth: sourceSize.width,
76 sourceHeight: sourceSize.height,
77 availableWidth,
78 });
79 const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);
80 const placeholderWidth: ViewStyle["width"] =
81 availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
82 const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
83
84 return (
85 <View
86 onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
87 style={{ alignSelf: "stretch", gap: 6 }}
88 >
89 {props.uri === null || failed ? (
90 <MediaActionsMenu media={mediaActions}>
91 <Pressable
92 accessibilityRole="imagebutton"
93 accessibilityLabel={props.alt ?? "Markdown image"}
94 accessibilityHint={
95 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
96 }
97 className="items-center justify-center rounded-[10px] bg-md-code-bg"
98 style={frameStyle}
99 >
100 {failed ? (
101 <Text className="px-3 text-center text-xs text-foreground-muted">
102 {props.unavailableDetail
103 ? `Image unavailable. ${props.unavailableDetail}`
104 : "Image unavailable"}
105 </Text>
106 ) : (
107 <AppActivityIndicator />
108 )}
109 </Pressable>
110 </MediaActionsMenu>
111 ) : (
112 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
113 <MediaActionsMenu media={mediaActions}>
114 <Pressable
115 accessibilityRole="imagebutton"
116 accessibilityLabel={props.alt ?? "Markdown image"}
117 accessibilityHint={
118 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
119 }
120 onPress={() =>
121 props.onPressPreview(
122 withGallery({
123 kind: "image",
124 uri: props.uri!,
125 name: props.actionsSource?.name ?? props.alt ?? "Image",
126 sourceIdentifier,
127 actionsSource: props.actionsSource,
128 }),
129 )
130 }
131 style={{ alignSelf: "flex-start" }}
132 >
133 <View
134 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
135 style={frameStyle}
136 >
137 <ThreadMarkdownImageRequest
138 key={props.uri}
139 uri={props.uri}
140 format={props.format}
141 onLoad={setDecodedSize}
142 onError={() => setFailedUri(props.uri)}
143 />
144 </View>
145 </Pressable>
146 </MediaActionsMenu>
147 </PresentationSource>
148 )}
149 {props.alt ? (
150 <Text selectable className="text-xs text-foreground-muted">
151 {props.alt}
152 </Text>
153 ) : null}
154 </View>
155 );
156}
157
158function ThreadMarkdownImageRequest(props: {
159 readonly uri: string;
160 readonly onLoad: (sourceSize: { width: number; height: number }) => void;
161 readonly onError: () => void;
162 readonly format?: "svg";
163}) {
164 const [loaded, setLoaded] = useState(false);
165
166 return (
167 <>
168 {props.format === "svg" ? (
169 <ThreadMarkdownSvg
170 uri={props.uri}
171 onLoad={(size) => {
172 setLoaded(true);
173 if (size) props.onLoad(size);
174 }}
175 onError={props.onError}
176 />
177 ) : (
178 <Image
179 source={{ uri: props.uri }}
180 contentFit="contain"
181 cachePolicy="memory-disk"
182 accessible={false}
183 onLoad={(event) => {
184 setLoaded(true);
185 props.onLoad(event.source);
186 }}
187 onError={props.onError}
188 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
189 />
190 )}
191 {loaded ? null : (
192 <View
193 pointerEvents="none"
194 style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
195 >
196 <Text className="text-xs text-foreground-muted">Loading image…</Text>
197 </View>
198 )}
199 </>
200 );
201}
202
203function ThreadMarkdownSvg(props: {
204 readonly uri: string;
205 readonly onLoad: (size: { width: number; height: number } | null) => void;
206 readonly onError: () => void;
207}) {
208 const [ast, setAst] = useState<JsxAST | null>(null);
209 const onLoad = useEffectEvent(props.onLoad);
210 const onError = useEffectEvent(props.onError);
211 useEffect(() => {
212 const controller = new AbortController();
213 const load = async () => {
214 if (props.uri.startsWith("data:image/svg+xml")) {
215 const comma = props.uri.indexOf(",");
216 if (comma < 0) throw new Error("SVG data URI has no content");
217 const content = decodeURIComponent(props.uri.slice(comma + 1));
218 return props.uri.slice(0, comma).includes(";base64")
219 ? new TextDecoder().decode(
220 Uint8Array.from(atob(content), (character) => character.charCodeAt(0)),
221 )
222 : content;
223 }
224 const response = await fetch(props.uri, { signal: controller.signal });
225 if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://")))
226 throw new Error(`SVG request failed: ${response.status}`);
227 return response.text();
228 };
229 void load()
230 .then((xml) => {
231 const parsed = parse(xml);
232 if (!parsed) throw new Error("SVG has no root element");
233 if (controller.signal.aborted) return;
234 setAst(parsed);
235 onLoad(resolveSvgImageSize(parsed.props));
236 })
237 .catch(() => {
238 if (!controller.signal.aborted) onError();
239 });
240 return () => controller.abort();
241 }, [props.uri]);
242 return <SvgAst ast={ast} override={{ width: "100%", height: "100%", accessible: false }} />;
243}
244
245/** Environment-hosted image that loads through a signed asset URL. */
246export function ThreadMarkdownImage(props: {
247 readonly environmentId: EnvironmentId;
248 readonly resource: Extract<
249 AssetResource,
250 { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "tool-output-image" }
251 >;
252 readonly alt: string | null;
253 readonly srcFragment?: string;
254 readonly actionsSource?: MediaActionsSource;
255 readonly onPressPreview: (source: FilePreviewSource) => void;
256}) {
257 const withGallery = useFilePreviewGallery();
258 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
259
260 return (
261 <ThreadMarkdownImageView
262 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
263 sourceKey={
264 props.resource._tag === "attachment"
265 ? `attachment:${props.resource.attachmentId}`
266 : props.resource._tag === "tool-output-image"
267 ? `tool-output:${props.resource.itemId}:${props.resource.index}`
268 : `workspace:${props.resource.path}`
269 }
270 unavailable={assetUrl._tag === "Failure"}
271 unavailableDetail={
272 assetUrl._tag === "Failure" && assetUrl.reason === "missing-file-access"
273 ? MISSING_FILE_ACCESS_MESSAGE
274 : undefined
275 }
276 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
277 format={
278 (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") &&
279 /\.svg$/i.test(props.resource.path)
280 ? "svg"
281 : undefined
282 }
283 alt={props.alt}
284 actionsSource={props.actionsSource}
285 onPressPreview={(source) => {
286 const { uri: _uri, ...rest } = source as FilePreviewSource & { uri?: string };
287 props.onPressPreview(
288 withGallery({
289 ...rest,
290 gallery: undefined,
291 environmentId: props.environmentId,
292 resource: props.resource,
293 }),
294 );
295 }}
296 />
297 );
298}
299
300export function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
301 return (
302 <ThreadMarkdownImageView
303 uri={null}
304 sourceKey="unavailable"
305 unavailable
306 alt={props.alt}
307 onPressPreview={() => undefined}
308 />
309 );
310}