authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-04 00:27:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 21:27:16-07:00
log09b81a34954c990f70257ae05efbb602c90aac97
treeafd2398216a5e68fa0185e32cd86eac997258348
parent2675e3c70327719a99af4ae6e53e7b74fb8a9be0
signature Signed by PGP key B5690EEEBB952194

fix(mobile): render workspace images in markdown file previews (#8769)


4 files changed, 265 insertions(+), 186 deletions(-)

apps/mobile/src/features/files/FileMarkdownPreview.tsx+54-2
......@@ -1,3 +1,6 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
3import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects";
14import { useCallback, useMemo, useState } from "react";
25import {
36 Markdown,
......@@ -14,12 +17,18 @@ import {
1417 resolveNativeMarkdownTypography,
1518} from "../../lib/appearancePreferences";
1619import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import {
21 ThreadMarkdownImage,
22 ThreadMarkdownImageUnavailable,
23} from "../threads/ThreadMarkdownImage";
1724import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1825import {
1926 hasNativeSelectableMarkdownText,
2027 SelectableMarkdownText,
28 type MarkdownImageRenderer,
2129 type NativeMarkdownTextStyle,
2230} from "../../native/SelectableMarkdownText";
31import { resolveWorkspaceFilePath } from "./filePath";
2332
2433interface MarkdownPreviewStyles {
2534 readonly theme: PartialMarkdownTheme;
......@@ -28,7 +37,7 @@ interface MarkdownPreviewStyles {
2837 readonly nativeTextStyle: NativeMarkdownTextStyle;
2938}
3039
31function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
40function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles {
3241 const { appearance } = useAppearancePreferences();
3342 const markdownFontSizes = useMemo(
3443 () => resolveMarkdownFontSizes(appearance.baseFontSize),
......@@ -69,6 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
6978 {children}
7079 </NativeText>
7180 ),
81 image: ({ node }) =>
82 node.href && renderImage
83 ? (renderImage({
84 href: node.href,
85 alt: node.alt ?? null,
86 title: node.title ?? null,
87 }) ?? undefined)
88 : undefined,
7289 };
7390
7491 return {
......@@ -166,13 +183,18 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
166183 mediumFontFamily,
167184 nativeMarkdownTypography,
168185 regularFontFamily,
186 renderImage,
169187 strong,
170188 boldFontFamily,
171189 ]);
172190}
173191
174192export function FileMarkdownPreview(props: {
193 readonly cwd: string;
194 readonly environmentId: EnvironmentId;
175195 readonly markdown: string;
196 readonly relativePath: string;
197 readonly threadId: ThreadId;
176198 readonly onRefresh?: () => Promise<void> | void;
177199}) {
178200 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
......@@ -187,7 +209,36 @@ export function FileMarkdownPreview(props: {
187209 setIsPullRefreshing(false);
188210 }
189211 }, [props.onRefresh]);
190 const styles = useMarkdownPreviewStyles();
212 const markdownDirectory = useMemo(
213 () => getBrowseDirectoryPath(resolveWorkspaceFilePath(props.cwd, props.relativePath)),
214 [props.cwd, props.relativePath],
215 );
216 const renderImage = useCallback<MarkdownImageRenderer>(
217 (image) => {
218 const media = resolveMediaSource(image.href, {
219 threadId: props.threadId,
220 workspaceRoot: markdownDirectory,
221 imageEmbed: true,
222 });
223 if (media?.access === "direct") {
224 return null;
225 }
226 if (media === null || media.kind !== "image" || media.access === "unavailable") {
227 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
228 }
229 return (
230 <ThreadMarkdownImage
231 environmentId={props.environmentId}
232 resource={media.resource}
233 alt={image.alt}
234 srcFragment={media.srcFragment}
235 onPressPreview={() => undefined}
236 />
237 );
238 },
239 [markdownDirectory, props.environmentId, props.threadId],
240 );
241 const styles = useMarkdownPreviewStyles(renderImage);
191242 const onLinkPress = useCallback((href: string) => {
192243 void tryOpenExternalUrl(href, "markdown-link");
193244 }, []);
......@@ -210,6 +261,7 @@ export function FileMarkdownPreview(props: {
210261 <SelectableMarkdownText
211262 markdown={props.markdown}
212263 onLinkPress={onLinkPress}
264 renderImage={renderImage}
213265 textStyle={styles.nativeTextStyle}
214266 />
215267 ) : (
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+13-2
......@@ -106,7 +106,8 @@ function defaultViewMode(path: string | null): FileViewMode {
106106
107107function FileContent(props: {
108108 readonly activeMode: FileViewMode;
109 readonly environmentId: EnvironmentId | null;
109 readonly cwd: string;
110 readonly environmentId: EnvironmentId;
110111 readonly previewUri: string | null;
111112 readonly previewFailure: AssetUrlFailureReason | null;
112113 readonly onRetryPreview: () => void;
......@@ -116,6 +117,7 @@ function FileContent(props: {
116117 readonly fileContents: string | null;
117118 readonly fileError: string | null;
118119 readonly relativePath: string;
120 readonly threadId: ThreadId;
119121 readonly initialLine: number | null;
120122 readonly truncated: boolean;
121123 readonly onRefresh?: () => Promise<void> | void;
......@@ -199,7 +201,14 @@ function FileContent(props: {
199201 </View>
200202 ) : null}
201203 {props.activeMode === "preview" && isMarkdown ? (
202 <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} />
204 <FileMarkdownPreview
205 cwd={props.cwd}
206 environmentId={props.environmentId}
207 markdown={props.fileContents}
208 relativePath={props.relativePath}
209 threadId={props.threadId}
210 onRefresh={props.onRefresh}
211 />
203212 ) : (
204213 <SourceFileSurface
205214 contents={props.fileContents}
......@@ -910,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
910919 <FileContent
911920 key={previewKey}
912921 activeMode={resolvedActiveMode}
922 cwd={cwd}
913923 environmentId={environmentId}
914924 previewUri={previewUri}
915925 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
......@@ -921,6 +931,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
921931 fileError={fileQuery.error}
922932 initialLine={targetLine}
923933 relativePath={relativePath}
934 threadId={threadId}
924935 truncated={fileData?.truncated ?? false}
925936 onRefresh={() => fileQuery.refresh()}
926937 />
apps/mobile/src/features/threads/ThreadFeed.tsx+5-182
......@@ -2,7 +2,6 @@ import * as Haptics from "expo-haptics";
22import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
44import type {
5 AssetResource,
65 ChatAttachment,
76 ChatFileAttachment,
87 ChatImageAttachment,
......@@ -68,7 +67,6 @@ import {
6867 type ColorValue,
6968 useWindowDimensions,
7069 View,
71 type ViewStyle,
7270} from "react-native";
7371import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
7472import { isPdfFile } from "../../lib/filePreview";
......@@ -102,8 +100,6 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid
102100import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
103101import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
104102import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
105import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
106import { MediaActionsMenu } from "../../components/MediaActionsMenu";
107103import {
108104 attachmentVideoPreviewSource,
109105 mediaVideoPreviewUri,
......@@ -178,8 +174,12 @@ import {
178174 isAbsolutePath,
179175 resolveWorkspaceRelativeFilePath,
180176} from "../files/filePath";
181import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
182177import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
178import {
179 ThreadMarkdownImage,
180 ThreadMarkdownImageUnavailable,
181 ThreadMarkdownImageView,
182} from "./ThreadMarkdownImage";
183183
184184const WIDE_MARKDOWN_BLOCK_OPTIONS = {
185185 // Native iOS blockquotes and adjacent selectable text are separate layout
......@@ -505,171 +505,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) {
505505 );
506506}
507507
508function ThreadMarkdownImageView(props: {
509 readonly uri: string | null;
510 readonly sourceKey: string;
511 readonly unavailable: boolean;
512 readonly alt: string | null;
513 readonly actionsSource?: MediaActionsSource;
514 readonly onPressPreview: (source: FilePreviewSource) => void;
515}) {
516 const sourceIdentifier = useId();
517 const mediaActions = useMediaActions(props.actionsSource);
518 const [availableWidth, setAvailableWidth] = useState(0);
519 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
520 const [failedUri, setFailedUri] = useState<string | null>(null);
521
522 useEffect(() => {
523 setSourceSize(null);
524 }, [props.sourceKey]);
525
526 useEffect(() => {
527 setFailedUri(null);
528 }, [props.uri]);
529
530 const displaySize =
531 sourceSize === null
532 ? null
533 : resolveMarkdownImageDisplaySize({
534 sourceWidth: sourceSize.width,
535 sourceHeight: sourceSize.height,
536 availableWidth,
537 });
538 const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);
539 const placeholderWidth: ViewStyle["width"] =
540 availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
541 const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
542
543 return (
544 <View
545 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
546 style={{ alignSelf: "stretch", gap: 6 }}
547 >
548 {props.uri === null || failed ? (
549 <MediaActionsMenu media={mediaActions}>
550 <Pressable
551 accessibilityRole="imagebutton"
552 accessibilityLabel={props.alt ?? "Markdown image"}
553 accessibilityHint={
554 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
555 }
556 className="items-center justify-center rounded-[10px] bg-md-code-bg"
557 style={frameStyle}
558 >
559 {failed ? (
560 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
561 ) : (
562 <ActivityIndicator />
563 )}
564 </Pressable>
565 </MediaActionsMenu>
566 ) : (
567 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
568 <MediaActionsMenu media={mediaActions}>
569 <Pressable
570 accessibilityRole="imagebutton"
571 accessibilityLabel={props.alt ?? "Markdown image"}
572 accessibilityHint={
573 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
574 }
575 onPress={() =>
576 // Quick Look picks the viewer from the name's extension, so it needs the
577 // file name rather than the alt text.
578 props.onPressPreview({
579 kind: "image",
580 uri: props.uri!,
581 name: props.actionsSource?.name ?? props.alt ?? "Image",
582 sourceIdentifier,
583 actionsSource: props.actionsSource,
584 })
585 }
586 style={{ alignSelf: "flex-start" }}
587 >
588 <View
589 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
590 style={{
591 ...frameStyle,
592 }}
593 >
594 <ThreadMarkdownImageRequest
595 key={props.uri}
596 uri={props.uri}
597 onLoad={setSourceSize}
598 onError={() => setFailedUri(props.uri)}
599 />
600 </View>
601 </Pressable>
602 </MediaActionsMenu>
603 </PresentationSource>
604 )}
605 {props.alt ? (
606 <Text selectable className="text-xs text-foreground-muted">
607 {props.alt}
608 </Text>
609 ) : null}
610 </View>
611 );
612}
613
614function ThreadMarkdownImageRequest(props: {
615 readonly uri: string;
616 readonly onLoad: (sourceSize: { width: number; height: number }) => void;
617 readonly onError: () => void;
618}) {
619 const [loaded, setLoaded] = useState(false);
620
621 return (
622 <>
623 <Image
624 source={{ uri: props.uri }}
625 resizeMode="contain"
626 accessible={false}
627 onLoad={(event) => {
628 setLoaded(true);
629 props.onLoad(event.nativeEvent.source);
630 }}
631 onError={props.onError}
632 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
633 />
634 {loaded ? null : (
635 <View
636 pointerEvents="none"
637 style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
638 >
639 <Text className="text-xs text-foreground-muted">Loading image…</Text>
640 </View>
641 )}
642 </>
643 );
644}
645
646/** Environment-hosted image that loads through a signed asset URL. */
647function ThreadMarkdownImage(props: {
648 readonly environmentId: EnvironmentId;
649 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
650 readonly alt: string | null;
651 readonly srcFragment?: string;
652 readonly actionsSource?: MediaActionsSource;
653 readonly onPressPreview: (source: FilePreviewSource) => void;
654}) {
655 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
656
657 return (
658 <ThreadMarkdownImageView
659 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
660 sourceKey={
661 props.resource._tag === "attachment"
662 ? `attachment:${props.resource.attachmentId}`
663 : `workspace:${props.resource.path}`
664 }
665 unavailable={assetUrl._tag === "Failure"}
666 alt={props.alt}
667 actionsSource={props.actionsSource}
668 onPressPreview={props.onPressPreview}
669 />
670 );
671}
672
673508const ThreadMediaVisibleContext = createContext(false);
674509// LegendList only computes hook visibility when the list has a viewability config.
675510const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
......@@ -713,18 +548,6 @@ function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }
713548 );
714549}
715550
716function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
717 return (
718 <ThreadMarkdownImageView
719 uri={null}
720 sourceKey="unavailable"
721 unavailable
722 alt={props.alt}
723 onPressPreview={() => undefined}
724 />
725 );
726}
727
728551const MARKDOWN_MONO_FONT = Platform.select({
729552 ios: "ui-monospace",
730553 android: "monospace",
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx created+193
......@@ -0,0 +1,193 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useId, useState } from "react";
3import {
4 ActivityIndicator,
5 Image,
6 Pressable,
7 StyleSheet,
8 View,
9 type ViewStyle,
10} from "react-native";
11
12import { AppText as Text } from "../../components/AppText";
13import type { FilePreviewSource } from "../../components/FilePreviewModal";
14import { MediaActionsMenu } from "../../components/MediaActionsMenu";
15import { PresentationSource } from "../../components/NativePresentation";
16import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
17import { useAssetUrlState } from "../../state/assets";
18import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
19
20export function ThreadMarkdownImageView(props: {
21 readonly uri: string | null;
22 readonly sourceKey: string;
23 readonly unavailable: boolean;
24 readonly alt: string | null;
25 readonly actionsSource?: MediaActionsSource;
26 readonly onPressPreview: (source: FilePreviewSource) => void;
27}) {
28 const sourceIdentifier = useId();
29 const mediaActions = useMediaActions(props.actionsSource);
30 const [availableWidth, setAvailableWidth] = useState(0);
31 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
32 const [failedUri, setFailedUri] = useState<string | null>(null);
33
34 useEffect(() => {
35 setSourceSize(null);
36 }, [props.sourceKey]);
37
38 useEffect(() => {
39 setFailedUri(null);
40 }, [props.uri]);
41
42 const displaySize =
43 sourceSize === null
44 ? null
45 : resolveMarkdownImageDisplaySize({
46 sourceWidth: sourceSize.width,
47 sourceHeight: sourceSize.height,
48 availableWidth,
49 });
50 const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);
51 const placeholderWidth: ViewStyle["width"] =
52 availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
53 const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
54
55 return (
56 <View
57 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
58 style={{ alignSelf: "stretch", gap: 6 }}
59 >
60 {props.uri === null || failed ? (
61 <MediaActionsMenu media={mediaActions}>
62 <Pressable
63 accessibilityRole="imagebutton"
64 accessibilityLabel={props.alt ?? "Markdown image"}
65 accessibilityHint={
66 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
67 }
68 className="items-center justify-center rounded-[10px] bg-md-code-bg"
69 style={frameStyle}
70 >
71 {failed ? (
72 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
73 ) : (
74 <ActivityIndicator />
75 )}
76 </Pressable>
77 </MediaActionsMenu>
78 ) : (
79 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
80 <MediaActionsMenu media={mediaActions}>
81 <Pressable
82 accessibilityRole="imagebutton"
83 accessibilityLabel={props.alt ?? "Markdown image"}
84 accessibilityHint={
85 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
86 }
87 onPress={() =>
88 props.onPressPreview({
89 kind: "image",
90 uri: props.uri!,
91 name: props.actionsSource?.name ?? props.alt ?? "Image",
92 sourceIdentifier,
93 actionsSource: props.actionsSource,
94 })
95 }
96 style={{ alignSelf: "flex-start" }}
97 >
98 <View
99 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
100 style={{
101 ...frameStyle,
102 }}
103 >
104 <ThreadMarkdownImageRequest
105 key={props.uri}
106 uri={props.uri}
107 onLoad={setSourceSize}
108 onError={() => setFailedUri(props.uri)}
109 />
110 </View>
111 </Pressable>
112 </MediaActionsMenu>
113 </PresentationSource>
114 )}
115 {props.alt ? (
116 <Text selectable className="text-xs text-foreground-muted">
117 {props.alt}
118 </Text>
119 ) : null}
120 </View>
121 );
122}
123
124function ThreadMarkdownImageRequest(props: {
125 readonly uri: string;
126 readonly onLoad: (sourceSize: { width: number; height: number }) => void;
127 readonly onError: () => void;
128}) {
129 const [loaded, setLoaded] = useState(false);
130
131 return (
132 <>
133 <Image
134 source={{ uri: props.uri }}
135 resizeMode="contain"
136 accessible={false}
137 onLoad={(event) => {
138 setLoaded(true);
139 props.onLoad(event.nativeEvent.source);
140 }}
141 onError={props.onError}
142 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
143 />
144 {loaded ? null : (
145 <View
146 pointerEvents="none"
147 style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
148 >
149 <Text className="text-xs text-foreground-muted">Loading image…</Text>
150 </View>
151 )}
152 </>
153 );
154}
155
156/** Environment-hosted image that loads through a signed asset URL. */
157export function ThreadMarkdownImage(props: {
158 readonly environmentId: EnvironmentId;
159 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
160 readonly alt: string | null;
161 readonly srcFragment?: string;
162 readonly actionsSource?: MediaActionsSource;
163 readonly onPressPreview: (source: FilePreviewSource) => void;
164}) {
165 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
166
167 return (
168 <ThreadMarkdownImageView
169 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
170 sourceKey={
171 props.resource._tag === "attachment"
172 ? `attachment:${props.resource.attachmentId}`
173 : `workspace:${props.resource.path}`
174 }
175 unavailable={assetUrl._tag === "Failure"}
176 alt={props.alt}
177 actionsSource={props.actionsSource}
178 onPressPreview={props.onPressPreview}
179 />
180 );
181}
182
183export function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
184 return (
185 <ThreadMarkdownImageView
186 uri={null}
187 sourceKey="unavailable"
188 unavailable
189 alt={props.alt}
190 onPressPreview={() => undefined}
191 />
192 );
193}