Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import type {
/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null);

/** Remote/data URIs the fallback Image can load. Workspace paths must go through renderImage. */
const DIRECT_MARKDOWN_IMAGE_HREF_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;

type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;

const highlightedCodeCache = new Map<string, HighlightedCode>();
Expand Down Expand Up @@ -382,8 +385,10 @@ function NativeMarkdownImage(props: {
readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer | null;
}) {
const renderImage = useContext(MarkdownImageRendererContext);
const contextRenderer = useContext(MarkdownImageRendererContext);
const renderImage = props.renderImage ?? contextRenderer;
const href = props.node.href;
if (!href) {
return (
Expand All @@ -407,32 +412,65 @@ function NativeMarkdownImage(props: {
}
}

const alt = props.node.alt ?? props.node.title;
const caption = props.node.alt ? (
<Text
selectable
style={{
color: props.textStyle.mutedColor,
fontFamily: props.textStyle.fontFamily,
fontSize: 12,
lineHeight: 16,
}}
>
{props.node.alt}
</Text>
) : null;

if (!DIRECT_MARKDOWN_IMAGE_HREF_PATTERN.test(href.trim())) {
return (
<View style={{ gap: 6 }}>
<View
accessibilityLabel={alt ?? "Image unavailable"}
style={{
width: "100%",
aspectRatio: 16 / 9,
alignItems: "center",
justifyContent: "center",
backgroundColor: props.textStyle.codeBackgroundColor,
borderRadius: 10,
}}
>
<Text
style={{
color: props.textStyle.mutedColor,
fontFamily: props.textStyle.fontFamily,
fontSize: 12,
lineHeight: 16,
}}
>
Image unavailable
</Text>
</View>
{caption}
</View>
);
}

return (
<View style={{ gap: 6 }}>
<Image
source={{ uri: href }}
resizeMode="contain"
accessibilityLabel={props.node.alt ?? props.node.title}
accessibilityLabel={alt}
style={{
width: "100%",
aspectRatio: 16 / 9,
backgroundColor: props.textStyle.codeBackgroundColor,
borderRadius: 10,
}}
/>
{props.node.alt ? (
<Text
selectable
style={{
color: props.textStyle.mutedColor,
fontFamily: props.textStyle.fontFamily,
fontSize: 12,
lineHeight: 16,
}}
>
{props.node.alt}
</Text>
) : null}
{caption}
</View>
);
}
Expand Down Expand Up @@ -465,6 +503,7 @@ function NativeMixedParagraph(props: {
readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer | null;
}) {
return (
<View style={{ gap: 8 }}>
Expand All @@ -476,6 +515,7 @@ function NativeMixedParagraph(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
) : (
<SelectableNode
Expand All @@ -497,6 +537,7 @@ function NativeList(props: {
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer | null;
readonly depth: number;
}) {
const ordered = props.node.ordered ?? false;
Expand Down Expand Up @@ -559,6 +600,7 @@ function NativeList(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={props.depth + 1}
compact
/>
Expand All @@ -577,6 +619,7 @@ export function NativeMarkdownBlock(props: {
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer | null;
readonly depth?: number;
readonly compact?: boolean;
}) {
Expand All @@ -593,6 +636,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
/>
))}
Expand Down Expand Up @@ -623,6 +667,7 @@ export function NativeMarkdownBlock(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
);
case "horizontal_rule":
Expand Down Expand Up @@ -654,6 +699,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
compact
/>
Expand All @@ -668,6 +714,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
/>
);
Expand All @@ -678,6 +725,7 @@ export function NativeMarkdownBlock(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
) : (
<SelectableNode
Expand Down Expand Up @@ -725,6 +773,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
compact
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function SelectableMarkdownText({
textStyle={textStyle}
highlightCode={highlightCode}
onLinkPress={onLinkPress}
renderImage={renderImage}
/>
) : (
<NativeMarkdownSelectableText
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Haptics from "expo-haptics";
import { Image as ExpoImage } from "expo-image";
import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { type LegendListRef } from "@legendapp/list/react-native";
import type {
Expand Down Expand Up @@ -548,16 +549,21 @@ function ThreadMarkdownImageRequest(props: {

return (
<>
<Image
<ExpoImage
source={{ uri: props.uri }}
resizeMode="contain"
cachePolicy="memory-disk"
contentFit="contain"
accessible={false}
onLoad={(event) => {
const width = event.source.width;
const height = event.source.height;
if (width > 0 && height > 0) {
props.onLoad({ width, height });
}
setLoaded(true);
props.onLoad(event.nativeEvent.source);
}}
onError={props.onError}
style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
style={{ width: "100%", height: "100%" }}
/>
{loaded ? null : (
<View
Expand Down Expand Up @@ -2028,18 +2034,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const listAppearanceData = useMemo(
() => ({
copiedRowId,
environmentId: props.environmentId,
expandedWorkRows,
iconSubtleColor,
markdownStyles,
reviewCommentColors,
userBubbleColor,
viewportWidth,
workspaceRoot: props.workspaceRoot ?? null,
}),
[
copiedRowId,
expandedWorkRows,
iconSubtleColor,
markdownStyles,
props.environmentId,
props.workspaceRoot,
reviewCommentColors,
userBubbleColor,
viewportWidth,
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,36 @@ describe("nativeMarkdownDocumentChunks", () => {
});
});

it("keeps workspace markdown images in a rich chunk so the host can render them", () => {
const imageParagraph: MarkdownNode = {
type: "paragraph",
children: [
{
type: "image",
href: "media/shots/poster.png",
alt: "Poster — the identity lockup in the real studio",
},
],
};

const chunks = nativeMarkdownDocumentChunks({
type: "document",
children: [
{
type: "paragraph",
children: [{ type: "text", content: "The piece." }],
},
imageParagraph,
],
});

expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich"]);
expect(chunks[1]).toMatchObject({
kind: "rich",
node: imageParagraph,
});
});

it("keeps a plain list in one selectable native text container", () => {
const list: MarkdownNode = {
type: "list",
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/markdownImages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("classifyMarkdownImageSource", () => {

it.each([
["images/result.png", "/workspace/project", "/workspace/project/images/result.png"],
["media/shots/poster.png", "/workspace/project", "/workspace/project/media/shots/poster.png"],
["./images/result.png", "/workspace/project", "/workspace/project/./images/result.png"],
[
"images/result.png",
Expand Down Expand Up @@ -50,6 +51,7 @@ describe("classifyMarkdownImageSource", () => {
"#image",
"?image=1",
"image.png",
"media/shots/poster.png",
"~/image.png",
"javascript:alert(1)",
"ftp://example.com/image.png",
Expand Down
Loading