| 1 | import { createContext, useContext, useMemo, useState } from "react"; |
| 2 | import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; |
| 3 | import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; |
| 4 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; |
| 6 | import { videoMimeType } from "@t3tools/shared/video"; |
| 7 | import { Pressable, View } from "react-native"; |
| 8 | import { Image } from "expo-image"; |
| 9 | import { AppText } from "../../components/AppText"; |
| 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; |
| 11 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 12 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; |
| 13 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 14 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; |
| 15 | import { |
| 16 | FilePreviewModal, |
| 17 | type FilePreviewSource, |
| 18 | type FilePreviewItem, |
| 19 | } from "../../components/FilePreviewModal"; |
| 20 | |
| 21 | export const PullRequestMediaContext = createContext<{ |
| 22 | readonly environmentId: EnvironmentId; |
| 23 | readonly cwd: string; |
| 24 | } | null>(null); |
| 25 | |
| 26 | /** Only GitHub-hosted media needs the environment's authenticated asset path. */ |
| 27 | export function usePullRequestMediaUrl(url: string) { |
| 28 | const context = useContext(PullRequestMediaContext); |
| 29 | const fetchUrl = githubMediaFetchUrl(url); |
| 30 | const resource = useMemo<AssetResource | null>( |
| 31 | () => (context && fetchUrl ? { _tag: "github-media", cwd: context.cwd, url: fetchUrl } : null), |
| 32 | [context, fetchUrl], |
| 33 | ); |
| 34 | const asset = useAssetUrlState(context?.environmentId ?? null, resource); |
| 35 | const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); |
| 36 | return { |
| 37 | uri: asset._tag === "Success" ? asset.url : resource ? null : url, |
| 38 | unavailable: resource !== null && asset._tag === "Failure", |
| 39 | source: pullRequestMediaSource(url, context), |
| 40 | resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | export function PullRequestMedia(props: { |
| 45 | readonly url: string; |
| 46 | readonly kind: "image" | "video" | "unknown"; |
| 47 | readonly caption?: string; |
| 48 | readonly compact?: boolean; |
| 49 | }) { |
| 50 | const media = usePullRequestMediaUrl(props.url); |
| 51 | const context = useContext(PullRequestMediaContext); |
| 52 | const withGallery = useFilePreviewGallery(); |
| 53 | const [image, setImage] = useState<FilePreviewSource | null>(null); |
| 54 | const [video, setVideo] = useState<VideoPreviewSource | null>(null); |
| 55 | const name = props.caption || new URL(props.url).pathname.split("/").pop() || "Attachment"; |
| 56 | if (props.kind === "image") |
| 57 | return ( |
| 58 | <> |
| 59 | <ThreadMarkdownImageView |
| 60 | uri={media.uri} |
| 61 | sourceKey={props.url} |
| 62 | unavailable={media.unavailable} |
| 63 | alt={name} |
| 64 | onPressPreview={(source) => |
| 65 | setImage( |
| 66 | withGallery({ |
| 67 | ...pullRequestImageSource(props.url, context), |
| 68 | name, |
| 69 | sourceIdentifier: source.sourceIdentifier, |
| 70 | }), |
| 71 | ) |
| 72 | } |
| 73 | /> |
| 74 | <FilePreviewModal source={image} onRequestClose={() => setImage(null)} /> |
| 75 | </> |
| 76 | ); |
| 77 | return ( |
| 78 | <View style={props.compact ? { width: 176 } : undefined}> |
| 79 | {props.compact ? ( |
| 80 | <VideoAttachmentTile |
| 81 | name={name} |
| 82 | sourceIdentifier={props.url} |
| 83 | thumbnailSource={media.uri} |
| 84 | style={{ width: 176, height: 112, borderRadius: 16 }} |
| 85 | onPress={() => |
| 86 | setVideo({ |
| 87 | type: "media", |
| 88 | ...media.source, |
| 89 | name, |
| 90 | mimeType: |
| 91 | videoMimeType({ name: props.url.split(/[?#]/)[0] ?? props.url, mimeType: "" }) ?? |
| 92 | "video/mp4", |
| 93 | }) |
| 94 | } |
| 95 | /> |
| 96 | ) : ( |
| 97 | <MediaVideoPlayer |
| 98 | uri={media.uri} |
| 99 | unavailable={media.unavailable} |
| 100 | name={name} |
| 101 | thumbnailKey={props.url} |
| 102 | resolvePlaybackUri={media.resolvePlaybackUri} |
| 103 | /> |
| 104 | )} |
| 105 | <VideoPreviewModal source={video} onRequestClose={() => setVideo(null)} /> |
| 106 | </View> |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | export function PullRequestImageAttachment(props: { |
| 111 | readonly url: string; |
| 112 | readonly caption: string; |
| 113 | readonly source: FilePreviewItem; |
| 114 | readonly gallery: readonly FilePreviewItem[]; |
| 115 | }) { |
| 116 | const media = usePullRequestMediaUrl(props.url); |
| 117 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); |
| 118 | return ( |
| 119 | <> |
| 120 | <Pressable |
| 121 | accessibilityRole="imagebutton" |
| 122 | accessibilityLabel={`View attachment: ${props.caption}`} |
| 123 | onPress={() => setPreview({ ...props.source, gallery: props.gallery })} |
| 124 | className="gap-2 active:opacity-70" |
| 125 | style={{ width: 176 }} |
| 126 | > |
| 127 | <View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card"> |
| 128 | <Image |
| 129 | source={media.uri ? { uri: media.uri } : null} |
| 130 | style={{ width: 174, height: 112 }} |
| 131 | contentFit="contain" |
| 132 | cachePolicy="memory-disk" |
| 133 | /> |
| 134 | </View> |
| 135 | <AppText numberOfLines={1} className="text-xs text-foreground-muted"> |
| 136 | {props.caption} |
| 137 | </AppText> |
| 138 | </Pressable> |
| 139 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 140 | </> |
| 141 | ); |
| 142 | } |