authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-22 16:24:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 19:24:57-04:00
log5a7a7cf2925c88388a023f0d4eb6b9096884e817
tree05f7a0cb0e2ced8305c48ce81b1f2643b5de7e55
parent4e00471d1ab340b46742565bb6ce6462160b4d0c
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve markdown image dimensions (#7940)


3 files changed, 203 insertions(+), 46 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+104-46
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 type ColorValue,40 type ColorValue,
41 useWindowDimensions,41 useWindowDimensions,
42 View,42 View,
43 type ViewStyle,
43} from "react-native";44} from "react-native";
44import { TouchableOpacity } from "react-native-gesture-handler";45import { TouchableOpacity } from "react-native-gesture-handler";
45import ImageViewing from "react-native-image-viewing";46import ImageViewing from "react-native-image-viewing";
...@@ -105,6 +106,7 @@ import {...@@ -105,6 +106,7 @@ import {
105import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";106import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
106import { useAssetUrl, useAssetUrlState } from "../../state/assets";107import { useAssetUrl, useAssetUrlState } from "../../state/assets";
107import { resolveWorkspaceRelativeFilePath } from "../files/filePath";108import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
109import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
108110
109const WIDE_MARKDOWN_BLOCK_OPTIONS = {111const WIDE_MARKDOWN_BLOCK_OPTIONS = {
110 includeOrderedLists: Platform.OS === "android",112 includeOrderedLists: Platform.OS === "android",
...@@ -196,31 +198,50 @@ function MessageAttachmentImage(props: {...@@ -196,31 +198,50 @@ function MessageAttachmentImage(props: {
196 );198 );
197}199}
198200
199/** Markdown image whose src is a workspace file — loads through a signed asset URL. */201function ThreadMarkdownImageView(props: {
200function ThreadMarkdownImage(props: {202 readonly uri: string | null;
201 readonly environmentId: EnvironmentId;203 readonly sourceKey: string;
202 readonly threadId: ThreadId;204 readonly unavailable: boolean;
203 readonly path: string;
204 readonly alt: string | null;205 readonly alt: string | null;
205 readonly onPressImage: (uri: string) => void;206 readonly onPressImage: (uri: string) => void;
206}) {207}) {
207 const codeBackground = useThemeColor("--color-md-code-bg");208 const codeBackground = useThemeColor("--color-md-code-bg");
209 const [availableWidth, setAvailableWidth] = useState(0);
210 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
208 const [failedUri, setFailedUri] = useState<string | null>(null);211 const [failedUri, setFailedUri] = useState<string | null>(null);
209 const assetUrl = useAssetUrlState(props.environmentId, {212 const activeUriRef = useRef(props.uri);
210 _tag: "workspace-file",213 activeUriRef.current = props.uri;
211 threadId: props.threadId,214
212 path: props.path,215 useEffect(() => {
213 });216 setSourceSize(null);
214 const uri = assetUrl._tag === "Success" ? assetUrl.url : null;217 }, [props.sourceKey]);
215 const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri);218
219 useEffect(() => {
220 setFailedUri(null);
221 }, [props.uri]);
222
223 const displaySize =
224 sourceSize === null
225 ? null
226 : resolveMarkdownImageDisplaySize({
227 sourceWidth: sourceSize.width,
228 sourceHeight: sourceSize.height,
229 availableWidth,
230 });
231 const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);
232 const placeholderWidth: ViewStyle["width"] =
233 availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
234 const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
216235
217 return (236 return (
218 <View style={{ gap: 6 }}>237 <View
219 {uri === null || failed ? (238 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
239 style={{ alignSelf: "stretch", gap: 6 }}
240 >
241 {props.uri === null || failed ? (
220 <View242 <View
221 style={{243 style={{
222 width: "100%",244 ...frameStyle,
223 aspectRatio: 16 / 9,
224 borderRadius: 10,245 borderRadius: 10,
225 backgroundColor: codeBackground,246 backgroundColor: codeBackground,
226 alignItems: "center",247 alignItems: "center",
...@@ -238,20 +259,37 @@ function ThreadMarkdownImage(props: {...@@ -238,20 +259,37 @@ function ThreadMarkdownImage(props: {
238 accessibilityRole="imagebutton"259 accessibilityRole="imagebutton"
239 accessibilityLabel={props.alt ?? "Markdown image"}260 accessibilityLabel={props.alt ?? "Markdown image"}
240 activeOpacity={0.7}261 activeOpacity={0.7}
241 onPress={() => props.onPressImage(uri)}262 onPress={() => props.onPressImage(props.uri!)}
263 style={{ alignSelf: "flex-start" }}
242 >264 >
243 <Image265 <View
244 source={{ uri }}
245 resizeMode="contain"
246 accessible={false}
247 onError={() => setFailedUri(uri)}
248 style={{266 style={{
249 width: "100%",267 ...frameStyle,
250 aspectRatio: 16 / 9,
251 borderRadius: 10,268 borderRadius: 10,
252 backgroundColor: codeBackground,269 backgroundColor: codeBackground,
270 alignItems: "center",
271 justifyContent: "center",
272 overflow: "hidden",
253 }}273 }}
254 />274 >
275 <Image
276 source={{ uri: props.uri }}
277 resizeMode="contain"
278 accessible={false}
279 onLoad={(event) => {
280 if (activeUriRef.current !== props.uri) return;
281 const { width, height } = event.nativeEvent.source;
282 setSourceSize({ width, height });
283 }}
284 onError={() => setFailedUri(props.uri)}
285 style={{
286 width: "100%",
287 height: "100%",
288 opacity: displaySize === null ? 0 : 1,
289 }}
290 />
291 {displaySize === null ? <ActivityIndicator style={StyleSheet.absoluteFill} /> : null}
292 </View>
255 </TouchableOpacity>293 </TouchableOpacity>
256 )}294 )}
257 {props.alt ? (295 {props.alt ? (
...@@ -263,28 +301,40 @@ function ThreadMarkdownImage(props: {...@@ -263,28 +301,40 @@ function ThreadMarkdownImage(props: {
263 );301 );
264}302}
265303
304/** Markdown image whose src is a workspace file — loads through a signed asset URL. */
305function ThreadMarkdownImage(props: {
306 readonly environmentId: EnvironmentId;
307 readonly threadId: ThreadId;
308 readonly path: string;
309 readonly alt: string | null;
310 readonly onPressImage: (uri: string) => void;
311}) {
312 const assetUrl = useAssetUrlState(props.environmentId, {
313 _tag: "workspace-file",
314 threadId: props.threadId,
315 path: props.path,
316 });
317
318 return (
319 <ThreadMarkdownImageView
320 uri={assetUrl._tag === "Success" ? assetUrl.url : null}
321 sourceKey={props.path}
322 unavailable={assetUrl._tag === "Failure"}
323 alt={props.alt}
324 onPressImage={props.onPressImage}
325 />
326 );
327}
328
266function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {329function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
267 const codeBackground = useThemeColor("--color-md-code-bg");
268 return (330 return (
269 <View style={{ gap: 6 }}>331 <ThreadMarkdownImageView
270 <View332 uri={null}
271 style={{333 sourceKey="unavailable"
272 width: "100%",334 unavailable
273 aspectRatio: 16 / 9,335 alt={props.alt}
274 borderRadius: 10,336 onPressImage={() => undefined}
275 backgroundColor: codeBackground,337 />
276 alignItems: "center",
277 justifyContent: "center",
278 }}
279 >
280 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
281 </View>
282 {props.alt ? (
283 <Text selectable className="text-xs text-foreground-muted">
284 {props.alt}
285 </Text>
286 ) : null}
287 </View>
288 );338 );
289}339}
290340
...@@ -1530,7 +1580,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1530,7 +1580,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1530 (image) => {1580 (image) => {
1531 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);1581 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
1532 if (imageSource._tag === "Direct") {1582 if (imageSource._tag === "Direct") {
1533 return null;1583 return (
1584 <ThreadMarkdownImageView
1585 uri={imageSource.uri}
1586 sourceKey={imageSource.uri}
1587 unavailable={false}
1588 alt={image.alt}
1589 onPressImage={(uri) => setExpandedImage({ uri })}
1590 />
1591 );
1534 }1592 }
1535 if (imageSource._tag === "Blocked") {1593 if (imageSource._tag === "Blocked") {
1536 return <ThreadMarkdownImageUnavailable alt={image.alt} />;1594 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
apps/mobile/src/features/threads/markdownImageSize.test.ts created+62
...@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 MARKDOWN_IMAGE_MAX_HEIGHT,
5 MARKDOWN_IMAGE_MAX_WIDTH,
6 resolveMarkdownImageDisplaySize,
7} from "./markdownImageSize";
8
9describe("resolveMarkdownImageDisplaySize", () => {
10 it("keeps small images at their intrinsic size", () => {
11 expect(
12 resolveMarkdownImageDisplaySize({
13 sourceWidth: 96,
14 sourceHeight: 96,
15 availableWidth: 332,
16 }),
17 ).toEqual({ width: 96, height: 96 });
18 });
19
20 it("fits wide images to the available chat width", () => {
21 expect(
22 resolveMarkdownImageDisplaySize({
23 sourceWidth: 960,
24 sourceHeight: 540,
25 availableWidth: 332,
26 }),
27 ).toEqual({ width: 332, height: 186.75 });
28 });
29
30 it("caps wide images at 480 points on larger screens", () => {
31 expect(
32 resolveMarkdownImageDisplaySize({
33 sourceWidth: 960,
34 sourceHeight: 540,
35 availableWidth: 900,
36 }),
37 ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 });
38 });
39
40 it("caps tall images by height without changing their aspect ratio", () => {
41 expect(
42 resolveMarkdownImageDisplaySize({
43 sourceWidth: 400,
44 sourceHeight: 800,
45 availableWidth: 332,
46 }),
47 ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT });
48 });
49
50 it("rejects dimensions that cannot produce a stable layout", () => {
51 expect(
52 resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }),
53 ).toBeNull();
54 expect(
55 resolveMarkdownImageDisplaySize({
56 sourceWidth: 100,
57 sourceHeight: Number.NaN,
58 availableWidth: 332,
59 }),
60 ).toBeNull();
61 });
62});
apps/mobile/src/features/threads/markdownImageSize.ts created+37
...@@ -0,0 +1,37 @@
1export const MARKDOWN_IMAGE_MAX_WIDTH = 480;
2export const MARKDOWN_IMAGE_MAX_HEIGHT = 480;
3
4export interface MarkdownImageDisplaySize {
5 readonly width: number;
6 readonly height: number;
7}
8
9/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */
10export function resolveMarkdownImageDisplaySize(input: {
11 readonly sourceWidth: number;
12 readonly sourceHeight: number;
13 readonly availableWidth: number;
14}): MarkdownImageDisplaySize | null {
15 if (
16 !Number.isFinite(input.sourceWidth) ||
17 !Number.isFinite(input.sourceHeight) ||
18 !Number.isFinite(input.availableWidth) ||
19 input.sourceWidth <= 0 ||
20 input.sourceHeight <= 0 ||
21 input.availableWidth <= 0
22 ) {
23 return null;
24 }
25
26 const scale = Math.min(
27 1,
28 input.availableWidth / input.sourceWidth,
29 MARKDOWN_IMAGE_MAX_WIDTH / input.sourceWidth,
30 MARKDOWN_IMAGE_MAX_HEIGHT / input.sourceHeight,
31 );
32
33 return {
34 width: input.sourceWidth * scale,
35 height: input.sourceHeight * scale,
36 };
37}