| ... | @@ -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"; |
| 44 | import { TouchableOpacity } from "react-native-gesture-handler"; | 45 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 45 | import ImageViewing from "react-native-image-viewing"; | 46 | import ImageViewing from "react-native-image-viewing"; |
| ... | @@ -105,6 +106,7 @@ import { | ... | @@ -105,6 +106,7 @@ import { |
| 105 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 106 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 106 | import { useAssetUrl, useAssetUrlState } from "../../state/assets"; | 107 | import { useAssetUrl, useAssetUrlState } from "../../state/assets"; |
| 107 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 108 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| | 109 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; |
| 108 | | 110 | |
| 109 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { | 111 | const 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 | } |
| 198 | | 200 | |
| 199 | /** Markdown image whose src is a workspace file — loads through a signed asset URL. */ | 201 | function ThreadMarkdownImageView(props: { |
| 200 | function 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 }; |
| 216 | | 235 | |
| 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 | <View | 242 | <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 | <Image | 265 | <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 | } |
| 265 | | 303 | |
| | 304 | /** Markdown image whose src is a workspace file — loads through a signed asset URL. */ |
| | 305 | function 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 | |
| 266 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { | 329 | function 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 | <View | 332 | 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 | } |
| 290 | | 340 | |
| ... | @@ -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} />; |