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