| 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,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 | import { useCallback, useMemo, useState } from "react"; | 4 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import { | 5 | import { |
| 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"; |
| 16 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 19 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 20 | import { | ||
| 21 | ThreadMarkdownImage, | ||
| 22 | ThreadMarkdownImageUnavailable, | ||
| 23 | } from "../threads/ThreadMarkdownImage"; | ||
| 17 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 24 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 18 | import { | 25 | import { |
| 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"; |
| 31 | import { resolveWorkspaceFilePath } from "./filePath"; | ||
| 23 | 32 | ||
| 24 | interface MarkdownPreviewStyles { | 33 | interface 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 | } |
| 30 | 39 | ||
| 31 | function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | 40 | function 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 | }; |
| 73 | 90 | ||
| 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 | } |
| 173 | 191 | ||
| 174 | export function FileMarkdownPreview(props: { | 192 | export 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 | <SelectableMarkdownText | 261 | <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 { |
| 106 | 106 | ||
| 107 | function FileContent(props: { | 107 | function 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 | <SourceFileSurface | 213 | <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 | <FileContent | 919 | <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"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { | 4 | import 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"; |
| 73 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 71 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 74 | import { isPdfFile } from "../../lib/filePreview"; | 72 | import { 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 |
| 102 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; | 100 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; |
| 103 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | 101 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 104 | import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; | 102 | import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; |
| 105 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | ||
| 106 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | ||
| 107 | import { | 103 | import { |
| 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"; |
| 181 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; | ||
| 182 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; | 177 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; |
| 178 | import { | ||
| 179 | ThreadMarkdownImage, | ||
| 180 | ThreadMarkdownImageUnavailable, | ||
| 181 | ThreadMarkdownImageView, | ||
| 182 | } from "./ThreadMarkdownImage"; | ||
| 183 | 183 | ||
| 184 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { | 184 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { |
| 185 | // Native iOS blockquotes and adjacent selectable text are separate layout | 185 | // 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 | } |
| 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 | const ThreadMediaVisibleContext = createContext(false); | 508 | const 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. |
| 675 | const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; | 510 | const 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 | } |
| 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 | const MARKDOWN_MONO_FONT = Platform.select({ | 551 | const 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 @@ | ||
| 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 | } | ||