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