1import { createContext, useContext, useMemo, useState } from "react";
2import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource";
3import { useFilePreviewGallery } from "../../components/FilePreviewGallery";
4import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
5import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
6import { videoMimeType } from "@t3tools/shared/video";
7import { Pressable, View } from "react-native";
8import { Image } from "expo-image";
9import { AppText } from "../../components/AppText";
10import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
11import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
12import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
13import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
14import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
15import {
16 FilePreviewModal,
17 type FilePreviewSource,
18 type FilePreviewItem,
19} from "../../components/FilePreviewModal";
20
21export 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. */
27export 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
44export 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
110export 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}