| ... | ... | @@ -40,6 +40,7 @@ import { |
| 40 | 40 | type ColorValue, |
| 41 | 41 | useWindowDimensions, |
| 42 | 42 | View, |
| 43 | type ViewStyle, |
| 43 | 44 | } from "react-native"; |
| 44 | 45 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 45 | 46 | import ImageViewing from "react-native-image-viewing"; |
| ... | ... | @@ -105,6 +106,7 @@ import { |
| 105 | 106 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 106 | 107 | import { useAssetUrl, useAssetUrlState } from "../../state/assets"; |
| 107 | 108 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 109 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; |
| 108 | 110 | |
| 109 | 111 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { |
| 110 | 112 | includeOrderedLists: Platform.OS === "android", |
| ... | ... | @@ -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. */ |
| 200 | | function ThreadMarkdownImage(props: { |
| 201 | | readonly environmentId: EnvironmentId; |
| 202 | | readonly threadId: ThreadId; |
| 203 | | readonly path: string; |
| 201 | function ThreadMarkdownImageView(props: { |
| 202 | readonly uri: string | null; |
| 203 | readonly sourceKey: string; |
| 204 | readonly unavailable: boolean; |
| 204 | 205 | readonly alt: string | null; |
| 205 | 206 | readonly onPressImage: (uri: string) => void; |
| 206 | 207 | }) { |
| 207 | 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 | 211 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| 209 | | const assetUrl = useAssetUrlState(props.environmentId, { |
| 210 | | _tag: "workspace-file", |
| 211 | | threadId: props.threadId, |
| 212 | | path: props.path, |
| 213 | | }); |
| 214 | | const uri = assetUrl._tag === "Success" ? assetUrl.url : null; |
| 215 | | const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri); |
| 212 | const activeUriRef = useRef(props.uri); |
| 213 | activeUriRef.current = props.uri; |
| 214 | |
| 215 | useEffect(() => { |
| 216 | setSourceSize(null); |
| 217 | }, [props.sourceKey]); |
| 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 | 236 | return ( |
| 218 | | <View style={{ gap: 6 }}> |
| 219 | | {uri === null || failed ? ( |
| 237 | <View |
| 238 | onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} |
| 239 | style={{ alignSelf: "stretch", gap: 6 }} |
| 240 | > |
| 241 | {props.uri === null || failed ? ( |
| 220 | 242 | <View |
| 221 | 243 | style={{ |
| 222 | | width: "100%", |
| 223 | | aspectRatio: 16 / 9, |
| 244 | ...frameStyle, |
| 224 | 245 | borderRadius: 10, |
| 225 | 246 | backgroundColor: codeBackground, |
| 226 | 247 | alignItems: "center", |
| ... | ... | @@ -238,20 +259,37 @@ function ThreadMarkdownImage(props: { |
| 238 | 259 | accessibilityRole="imagebutton" |
| 239 | 260 | accessibilityLabel={props.alt ?? "Markdown image"} |
| 240 | 261 | activeOpacity={0.7} |
| 241 | | onPress={() => props.onPressImage(uri)} |
| 262 | onPress={() => props.onPressImage(props.uri!)} |
| 263 | style={{ alignSelf: "flex-start" }} |
| 242 | 264 | > |
| 243 | | <Image |
| 244 | | source={{ uri }} |
| 245 | | resizeMode="contain" |
| 246 | | accessible={false} |
| 247 | | onError={() => setFailedUri(uri)} |
| 265 | <View |
| 248 | 266 | style={{ |
| 249 | | width: "100%", |
| 250 | | aspectRatio: 16 / 9, |
| 267 | ...frameStyle, |
| 251 | 268 | borderRadius: 10, |
| 252 | 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 | 293 | </TouchableOpacity> |
| 256 | 294 | )} |
| 257 | 295 | {props.alt ? ( |
| ... | ... | @@ -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 | 329 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { |
| 267 | | const codeBackground = useThemeColor("--color-md-code-bg"); |
| 268 | 330 | return ( |
| 269 | | <View style={{ gap: 6 }}> |
| 270 | | <View |
| 271 | | style={{ |
| 272 | | width: "100%", |
| 273 | | aspectRatio: 16 / 9, |
| 274 | | borderRadius: 10, |
| 275 | | backgroundColor: codeBackground, |
| 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> |
| 331 | <ThreadMarkdownImageView |
| 332 | uri={null} |
| 333 | sourceKey="unavailable" |
| 334 | unavailable |
| 335 | alt={props.alt} |
| 336 | onPressImage={() => undefined} |
| 337 | /> |
| 288 | 338 | ); |
| 289 | 339 | } |
| 290 | 340 | |
| ... | ... | @@ -1530,7 +1580,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1530 | 1580 | (image) => { |
| 1531 | 1581 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); |
| 1532 | 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 | 1593 | if (imageSource._tag === "Blocked") { |
| 1536 | 1594 | return <ThreadMarkdownImageUnavailable alt={image.alt} />; |