Skip to content
Source
Sign inSign up

ThreadMarkdownImage.tsx

350 lines13.5 KB

1import { useAtomValue } from "@effect/atom-react";
2import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import { AsyncResult, Atom } from "effect/reactivity";
7import { Image } from "expo-image";
8import { createContext, useContext, useId, useState } from "react";
9import { fetch } from "expo/fetch";
10import { parse, SvgAst, type JsxAST } from "react-native-svg";
11import { Pressable, StyleSheet, View, type ViewStyle } from "react-native";
12​
13import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
14import { useFilePreviewGallery } from "../../components/ui/FilePreviewGallery";
15import { AppText as Text } from "../../components/ui/AppText";
16import type { FilePreviewSource } from "../../components/ui/FilePreviewModal";
17import { MediaActionsMenu } from "../../components/MediaActionsMenu";
18import { PresentationSource } from "../../components/ui/NativePresentation";
19import { useMediaActions } from "../../state/mediaActions";
20import type { MediaActionsSource } from "../../lib/mediaActionsSource";
21import { useAssetUrlState } from "../../state/assets";
22import { WorkspaceMutationIdContext } from "./workspaceMutationIdContext";
23import { ZoomableImagePressable } from "./ZoomableImagePressable";
24import { assetImageCacheKey } from "../../lib/assetImageSource";
25import {
26 MARKDOWN_IMAGE_MAX_WIDTH,
27 type MarkdownImageDisplaySize,
28 resolveMarkdownImageDisplaySize,
29 resolveSvgImageSize,
30} from "./markdownImageSize";
31​
32/** Use known feed width immediately; later image measurement would move already-positioned rows. */
33export const MarkdownImageAvailableWidthContext = createContext(0);
34​
35type ImageSize = { readonly width: number; readonly height: number };
36​
37class SvgLoadError extends Schema.TaggedError<SvgLoadError>()("SvgLoadError", {
38 cause: Schema.Defect(),
39}) {}
40​
41async function readSvgXml(uri: string, signal: AbortSignal): Promise<string> {
42 if (uri.startsWith("data:image/svg+xml")) {
43 const comma = uri.indexOf(",");
44 if (comma < 0) throw new Error("SVG data URI has no content");
45 const content = decodeURIComponent(uri.slice(comma + 1));
46 return uri.slice(0, comma).includes(";base64")
47 ? new TextDecoder().decode(
48 Uint8Array.from(atob(content), (character) => character.charCodeAt(0)),
49 )
50 : content;
51 }
52 // expo/fetch, not the runtime HttpClient: it is the one that reads file:// URIs.
53 const response = await fetch(uri, { signal });
54 if (!response.ok && !(response.status === 0 && uri.startsWith("file://")))
55 throw new Error(`SVG request failed: ${response.status}`);
56 return response.text();
58​
59type SvgDocument = { readonly ast: JsxAST; readonly size: ImageSize | null };
60​
61// Feed rows unmount as they scroll away. Only parsed documents outlive their
62// readers (for the row that scrolls back); a failed load is dropped with its last
63// reader so the next mount fetches again.
64const parsedSvgDocumentAtom = Atom.family((_uri: string) =>
65 Atom.make<SvgDocument | null>(null).pipe(
66 Atom.setIdleTTL(60_000),
67 Atom.withLabel("mobile:thread-markdown-svg-parsed"),
68 ),
69);
70​
71const svgDocumentAtom = Atom.family((uri: string) =>
72 Atom.make((get) => {
73 const parsed = parsedSvgDocumentAtom(uri);
74 const cached = get(parsed);
75 if (cached !== null) return Effect.succeed(cached);
76 return Effect.tryPromise({
77 try: async (signal): Promise<SvgDocument> => {
78 const xml = await readSvgXml(uri, signal);
79 signal.throwIfAborted();
80 const ast = parse(xml);
81 if (!ast) throw new Error("SVG has no root element");
82 return { ast, size: resolveSvgImageSize(ast.props) };
83 },
84 catch: (cause) => new SvgLoadError({ cause }),
85 }).pipe(Effect.tap((document) => Effect.sync(() => get.set(parsed, document))));
86 }).pipe(Atom.withLabel("mobile:thread-markdown-svg")),
87);
88​
89const noSvgDocumentAtom = Atom.make(AsyncResult.initial<SvgDocument, SvgLoadError>());
90​
91export function ThreadMarkdownImageView(props: {
92 readonly uri: string | null;
93 readonly sourceKey: string;
94 readonly cacheKey?: string | undefined;
95 readonly unavailable: boolean;
96 readonly unavailableDetail?: string | undefined;
97 readonly alt: string | null;
98 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
99 readonly format?: "svg";
100 readonly actionsSource?: MediaActionsSource;
101 readonly onPressPreview: (source: FilePreviewSource) => void;
102}) {
103 const withGallery = useFilePreviewGallery();
104 const sourceIdentifier = useId();
105 const mediaActions = useMediaActions(props.actionsSource);
106 const contextWidth = useContext(MarkdownImageAvailableWidthContext);
107 const [measuredWidth, setMeasuredWidth] = useState(0);
108 const availableWidth =
109 measuredWidth > 0 && contextWidth > 0
110 ? Math.min(contextWidth, measuredWidth)
111 : contextWidth || measuredWidth;
112 const svg = useAtomValue(
113 props.format === "svg" && props.uri !== null ? svgDocumentAtom(props.uri) : noSvgDocumentAtom,
114 );
115 const svgDocument = AsyncResult.isSuccess(svg) ? svg.value : null;
116 // Keyed by source so a new source drops the previous image's decoded dimensions.
117 const [decoded, setDecoded] = useState<{ sourceKey: string; size: ImageSize } | null>(null);
118 const decodedSize =
119 svgDocument?.size ?? (decoded?.sourceKey === props.sourceKey ? decoded.size : null);
120 // A failure belongs to one URI; any URI change, including through null, retries.
121 const [failure, setFailure] = useState({ uri: props.uri, failed: false });
122 if (failure.uri !== props.uri) setFailure({ uri: props.uri, failed: false });
123​
124 const sourceSize = decodedSize ?? props.knownSize ?? null;
125 const displaySize: MarkdownImageDisplaySize | null =
126 sourceSize === null || availableWidth <= 0
127 ? null
128 : resolveMarkdownImageDisplaySize({
129 sourceWidth: sourceSize.width,
130 sourceHeight: sourceSize.height,
131 availableWidth,
132 });
133 const failed =
134 props.unavailable ||
135 (props.uri !== null && failure.uri === props.uri && failure.failed) ||
136 AsyncResult.isFailure(svg);
137 const placeholderWidth: ViewStyle["width"] =
138 availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
139 const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
140​
141 return (
142 <View
143 onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
144 style={{ alignSelf: "stretch", gap: 6 }}
145 >
146 {props.uri === null || failed ? (
147 <MediaActionsMenu media={mediaActions}>
148 <Pressable
149 accessibilityRole="imagebutton"
150 accessibilityLabel={props.alt ?? "Markdown image"}
151 accessibilityHint={
152 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
153 }
154 className="items-center justify-center rounded-[10px] bg-md-code-bg"
155 style={frameStyle}
156 >
157 {failed ? (
158 <Text className="px-3 text-center text-xs text-foreground-muted">
159 {props.unavailableDetail
160 ? `Image unavailable. ${props.unavailableDetail}`
161 : "Image unavailable"}
162 </Text>
163 ) : (
164 <AppActivityIndicator />
165 )}
166 </Pressable>
167 </MediaActionsMenu>
168 ) : (
169 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
170 <MediaActionsMenu media={mediaActions}>
171 <ZoomableImagePressable
172 key={props.sourceKey}
173 accessibilityRole="imagebutton"
174 accessibilityLabel={props.alt ?? "Markdown image"}
175 accessibilityHint={
176 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
177 }
178 onPress={() =>
179 props.onPressPreview(
180 withGallery({
181 kind: "image",
182 uri: props.uri!,
183 name: props.actionsSource?.name ?? props.alt ?? "Image",
184 sourceIdentifier,
185 actionsSource: props.actionsSource,
186 }),
187 )
188 }
189 className="overflow-hidden rounded-[10px] bg-md-code-bg"
190 style={{ alignSelf: "flex-start", ...frameStyle }}
191 >
192 {props.format === "svg" ? (
193 <ThreadMarkdownSvg ast={svgDocument?.ast ?? null} />
194 ) : (
195 <ThreadMarkdownImageRequest
196 uri={props.uri}
197 cacheKey={props.cacheKey}
198 onLoad={(size) =>
199 // Only the first decode counts: later loads report the bitmap
200 // downsampled for the current frame (969 vs 970 wide), so keeping
201 // them resizes the frame, which reloads the image, and so on.
202 setDecoded((current) =>
203 current?.sourceKey === props.sourceKey
204 ? current
205 : { sourceKey: props.sourceKey, size },
206 )
207 }
208 onError={() => setFailure({ uri: props.uri, failed: true })}
209 />
210 )}
211 </ZoomableImagePressable>
212 </MediaActionsMenu>
213 </PresentationSource>
214 )}
215 {props.alt ? (
216 <Text selectable className="text-xs text-foreground-muted">
217 {props.alt}
218 </Text>
219 ) : null}
220 </View>
221 );
223​
224function ThreadMarkdownImageRequest(props: {
225 readonly uri: string;
226 readonly cacheKey: string | undefined;
227 readonly onLoad: (sourceSize: ImageSize) => void;
228 readonly onError: () => void;
229}) {
230 const [loaded, setLoaded] = useState(false);
231​
232 return (
233 <>
234 <Image
235 source={{ uri: props.uri, cacheKey: props.cacheKey }}
236 contentFit="contain"
237 cachePolicy="memory-disk"
238 accessible={false}
239 onLoad={(event) => {
240 setLoaded(true);
241 props.onLoad(event.source);
242 }}
243 onError={props.onError}
244 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
245 />
246 {loaded ? null : <ThreadMarkdownImageLoading />}
247 </>
248 );
250​
251function ThreadMarkdownImageLoading() {
252 return (
253 <View
254 pointerEvents="none"
255 style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
256 >
257 <Text className="text-xs text-foreground-muted">Loading image…</Text>
258 </View>
259 );
261​
262function ThreadMarkdownSvg(props: { readonly ast: JsxAST | null }) {
263 return props.ast === null ? (
264 <ThreadMarkdownImageLoading />
265 ) : (
266 <SvgAst ast={props.ast} override={{ width: "100%", height: "100%", accessible: false }} />
267 );
269​
270export function ThreadMarkdownImage(props: {
271 readonly environmentId: EnvironmentId;
272 readonly resource: Extract<
273 AssetResource,
274 { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "tool-output-image" }
275 >;
276 readonly alt: string | null;
277 readonly srcFragment?: string;
278 readonly actionsSource?: MediaActionsSource;
279 readonly onPressPreview: (source: FilePreviewSource) => void;
280}) {
281 const withGallery = useFilePreviewGallery();
282 const workspaceMutationId = useContext(WorkspaceMutationIdContext);
283 const assetUrl = useAssetUrlState(
284 props.environmentId,
285 props.resource,
286 props.resource._tag === "media-file" ? workspaceMutationId : undefined,
287 );
288 const resourceKey =
289 props.resource._tag === "attachment"
290 ? `attachment:${props.resource.attachmentId}`
291 : props.resource._tag === "tool-output-image"
292 ? `tool-output:${props.resource.itemId}:${props.resource.index}`
293 : `workspace:${props.resource.path}`;
294 // The view keeps the first decoded size per key, so a file edited in place needs a
295 // new key; a re-signed URL for the same revision must not get one.
296 const sourceKey =
297 assetUrl._tag === "Success" && assetUrl.fileRevision !== undefined
298 ? `${resourceKey}@${assetUrl.fileRevision}`
299 : resourceKey;
300​
301 return (
302 <ThreadMarkdownImageView
303 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
304 cacheKey={
305 assetUrl._tag === "Success"
306 ? assetImageCacheKey(props.environmentId, props.resource, assetUrl.fileRevision)
307 : undefined
308 }
309 sourceKey={sourceKey}
310 unavailable={assetUrl._tag === "Failure"}
311 unavailableDetail={
312 assetUrl._tag === "Failure" && assetUrl.reason === "missing-file-access"
313 ? MISSING_FILE_ACCESS_MESSAGE
314 : undefined
315 }
316 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
317 format={
318 (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") &&
319 /\.svg$/i.test(props.resource.path)
320 ? "svg"
321 : undefined
322 }
323 alt={props.alt}
324 actionsSource={props.actionsSource}
325 onPressPreview={(source) => {
326 const { uri: _uri, ...rest } = source as FilePreviewSource & { uri?: string };
327 props.onPressPreview(
328 withGallery({
329 ...rest,
330 gallery: undefined,
331 environmentId: props.environmentId,
332 resource: props.resource,
333 }),
334 );
335 }}
336 />
337 );
339​
340export function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
341 return (
342 <ThreadMarkdownImageView
343 uri={null}
344 sourceKey="unavailable"
345 unavailable
346 alt={props.alt}
347 onPressPreview={() => undefined}
348 />
349 );