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 @@...@@ -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";
1import { useCallback, useMemo, useState } from "react";4import { useCallback, useMemo, useState } from "react";
2import {5import {
3 Markdown,6 Markdown,
...@@ -14,12 +17,18 @@ import {...@@ -14,12 +17,18 @@ import {
14 resolveNativeMarkdownTypography,17 resolveNativeMarkdownTypography,
15} from "../../lib/appearancePreferences";18} from "../../lib/appearancePreferences";
16import { useUniwindTheme } from "../../lib/useUniwindTheme";19import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import {
21 ThreadMarkdownImage,
22 ThreadMarkdownImageUnavailable,
23} from "../threads/ThreadMarkdownImage";
17import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";24import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
18import {25import {
19 hasNativeSelectableMarkdownText,26 hasNativeSelectableMarkdownText,
20 SelectableMarkdownText,27 SelectableMarkdownText,
28 type MarkdownImageRenderer,
21 type NativeMarkdownTextStyle,29 type NativeMarkdownTextStyle,
22} from "../../native/SelectableMarkdownText";30} from "../../native/SelectableMarkdownText";
31import { resolveWorkspaceFilePath } from "./filePath";
2332
24interface MarkdownPreviewStyles {33interface MarkdownPreviewStyles {
25 readonly theme: PartialMarkdownTheme;34 readonly theme: PartialMarkdownTheme;
...@@ -28,7 +37,7 @@ interface MarkdownPreviewStyles {...@@ -28,7 +37,7 @@ interface MarkdownPreviewStyles {
28 readonly nativeTextStyle: NativeMarkdownTextStyle;37 readonly nativeTextStyle: NativeMarkdownTextStyle;
29}38}
3039
31function useMarkdownPreviewStyles(): MarkdownPreviewStyles {40function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles {
32 const { appearance } = useAppearancePreferences();41 const { appearance } = useAppearancePreferences();
33 const markdownFontSizes = useMemo(42 const markdownFontSizes = useMemo(
34 () => resolveMarkdownFontSizes(appearance.baseFontSize),43 () => resolveMarkdownFontSizes(appearance.baseFontSize),
...@@ -69,6 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -69,6 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
69 {children}78 {children}
70 </NativeText>79 </NativeText>
71 ),80 ),
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,
72 };89 };
7390
74 return {91 return {
...@@ -166,13 +183,18 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -166,13 +183,18 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
166 mediumFontFamily,183 mediumFontFamily,
167 nativeMarkdownTypography,184 nativeMarkdownTypography,
168 regularFontFamily,185 regularFontFamily,
186 renderImage,
169 strong,187 strong,
170 boldFontFamily,188 boldFontFamily,
171 ]);189 ]);
172}190}
173191
174export function FileMarkdownPreview(props: {192export function FileMarkdownPreview(props: {
193 readonly cwd: string;
194 readonly environmentId: EnvironmentId;
175 readonly markdown: string;195 readonly markdown: string;
196 readonly relativePath: string;
197 readonly threadId: ThreadId;
176 readonly onRefresh?: () => Promise<void> | void;198 readonly onRefresh?: () => Promise<void> | void;
177}) {199}) {
178 const [isPullRefreshing, setIsPullRefreshing] = useState(false);200 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
...@@ -187,7 +209,36 @@ export function FileMarkdownPreview(props: {...@@ -187,7 +209,36 @@ export function FileMarkdownPreview(props: {
187 setIsPullRefreshing(false);209 setIsPullRefreshing(false);
188 }210 }
189 }, [props.onRefresh]);211 }, [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);
191 const onLinkPress = useCallback((href: string) => {242 const onLinkPress = useCallback((href: string) => {
192 void tryOpenExternalUrl(href, "markdown-link");243 void tryOpenExternalUrl(href, "markdown-link");
193 }, []);244 }, []);
...@@ -210,6 +261,7 @@ export function FileMarkdownPreview(props: {...@@ -210,6 +261,7 @@ export function FileMarkdownPreview(props: {
210 <SelectableMarkdownText261 <SelectableMarkdownText
211 markdown={props.markdown}262 markdown={props.markdown}
212 onLinkPress={onLinkPress}263 onLinkPress={onLinkPress}
264 renderImage={renderImage}
213 textStyle={styles.nativeTextStyle}265 textStyle={styles.nativeTextStyle}
214 />266 />
215 ) : (267 ) : (
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+13-2
...@@ -106,7 +106,8 @@ function defaultViewMode(path: string | null): FileViewMode {...@@ -106,7 +106,8 @@ function defaultViewMode(path: string | null): FileViewMode {
106106
107function FileContent(props: {107function FileContent(props: {
108 readonly activeMode: FileViewMode;108 readonly activeMode: FileViewMode;
109 readonly environmentId: EnvironmentId | null;109 readonly cwd: string;
110 readonly environmentId: EnvironmentId;
110 readonly previewUri: string | null;111 readonly previewUri: string | null;
111 readonly previewFailure: AssetUrlFailureReason | null;112 readonly previewFailure: AssetUrlFailureReason | null;
112 readonly onRetryPreview: () => void;113 readonly onRetryPreview: () => void;
...@@ -116,6 +117,7 @@ function FileContent(props: {...@@ -116,6 +117,7 @@ function FileContent(props: {
116 readonly fileContents: string | null;117 readonly fileContents: string | null;
117 readonly fileError: string | null;118 readonly fileError: string | null;
118 readonly relativePath: string;119 readonly relativePath: string;
120 readonly threadId: ThreadId;
119 readonly initialLine: number | null;121 readonly initialLine: number | null;
120 readonly truncated: boolean;122 readonly truncated: boolean;
121 readonly onRefresh?: () => Promise<void> | void;123 readonly onRefresh?: () => Promise<void> | void;
...@@ -199,7 +201,14 @@ function FileContent(props: {...@@ -199,7 +201,14 @@ function FileContent(props: {
199 </View>201 </View>
200 ) : null}202 ) : null}
201 {props.activeMode === "preview" && isMarkdown ? (203 {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 />
203 ) : (212 ) : (
204 <SourceFileSurface213 <SourceFileSurface
205 contents={props.fileContents}214 contents={props.fileContents}
...@@ -910,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -910,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
910 <FileContent919 <FileContent
911 key={previewKey}920 key={previewKey}
912 activeMode={resolvedActiveMode}921 activeMode={resolvedActiveMode}
922 cwd={cwd}
913 environmentId={environmentId}923 environmentId={environmentId}
914 previewUri={previewUri}924 previewUri={previewUri}
915 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}925 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
...@@ -921,6 +931,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -921,6 +931,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
921 fileError={fileQuery.error}931 fileError={fileQuery.error}
922 initialLine={targetLine}932 initialLine={targetLine}
923 relativePath={relativePath}933 relativePath={relativePath}
934 threadId={threadId}
924 truncated={fileData?.truncated ?? false}935 truncated={fileData?.truncated ?? false}
925 onRefresh={() => fileQuery.refresh()}936 onRefresh={() => fileQuery.refresh()}
926 />937 />
apps/mobile/src/features/threads/ThreadFeed.tsx+5-182
...@@ -2,7 +2,6 @@ import * as Haptics from "expo-haptics";...@@ -2,7 +2,6 @@ import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
4import type {4import type {
5 AssetResource,
6 ChatAttachment,5 ChatAttachment,
7 ChatFileAttachment,6 ChatFileAttachment,
8 ChatImageAttachment,7 ChatImageAttachment,
...@@ -68,7 +67,6 @@ import {...@@ -68,7 +67,6 @@ import {
68 type ColorValue,67 type ColorValue,
69 useWindowDimensions,68 useWindowDimensions,
70 View,69 View,
71 type ViewStyle,
72} from "react-native";70} from "react-native";
73import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";71import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
74import { isPdfFile } from "../../lib/filePreview";72import { isPdfFile } from "../../lib/filePreview";
...@@ -102,8 +100,6 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid...@@ -102,8 +100,6 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid
102import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";100import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
103import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";101import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
104import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";102import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
105import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
106import { MediaActionsMenu } from "../../components/MediaActionsMenu";
107import {103import {
108 attachmentVideoPreviewSource,104 attachmentVideoPreviewSource,
109 mediaVideoPreviewUri,105 mediaVideoPreviewUri,
...@@ -178,8 +174,12 @@ import {...@@ -178,8 +174,12 @@ import {
178 isAbsolutePath,174 isAbsolutePath,
179 resolveWorkspaceRelativeFilePath,175 resolveWorkspaceRelativeFilePath,
180} from "../files/filePath";176} from "../files/filePath";
181import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
182import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";177import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
178import {
179 ThreadMarkdownImage,
180 ThreadMarkdownImageUnavailable,
181 ThreadMarkdownImageView,
182} from "./ThreadMarkdownImage";
183183
184const WIDE_MARKDOWN_BLOCK_OPTIONS = {184const WIDE_MARKDOWN_BLOCK_OPTIONS = {
185 // Native iOS blockquotes and adjacent selectable text are separate layout185 // Native iOS blockquotes and adjacent selectable text are separate layout
...@@ -505,171 +505,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) {...@@ -505,171 +505,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) {
505 );505 );
506}506}
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
673const ThreadMediaVisibleContext = createContext(false);508const ThreadMediaVisibleContext = createContext(false);
674// LegendList only computes hook visibility when the list has a viewability config.509// LegendList only computes hook visibility when the list has a viewability config.
675const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };510const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
...@@ -713,18 +548,6 @@ function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }...@@ -713,18 +548,6 @@ function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }
713 );548 );
714}549}
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
728const MARKDOWN_MONO_FONT = Platform.select({551const MARKDOWN_MONO_FONT = Platform.select({
729 ios: "ui-monospace",552 ios: "ui-monospace",
730 android: "monospace",553 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}