1import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
2import {
3 PullRequestImageAttachment,
4 PullRequestMedia,
5 PullRequestMediaContext,
6} from "./PullRequestMedia";
7import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
8import { useContext, useMemo, useState } from "react";
9import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
10import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
11import { pullRequestImageSource } from "./pullRequestMediaSource";
12import { parseMarkdownDocument } from "../../lib/markdownDocument";
13import { SymbolView } from "../../components/AppSymbol";
14import { AppText as Text } from "../../components/AppText";
15import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
16import type { FilePreviewItem } from "../../components/FilePreviewModal";
17import { isBotBadgeImage } from "./botBadgeImages";
18import { PullRequestMarkdown } from "./PullRequestMarkdown";
19import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
20
21const COLLAPSED_HEIGHT = 240;
22
23export function PullRequestDescription(props: {
24 readonly markdown: string;
25 readonly onCollapse: () => void;
26 readonly expanded: boolean;
27 readonly onExpandedChange: (expanded: boolean) => void;
28}) {
29 const { expanded, onExpandedChange } = props;
30 const [bodyHeight, setBodyHeight] = useState(0);
31 const mediaContext = useContext(PullRequestMediaContext);
32 const { themeVariables: colors } = useAppearancePreferences();
33 const fadeColor = colors[Platform.OS === "android" ? "--color-sheet-solid" : "--color-screen"];
34 const content = useMemo(() => {
35 const document = parseMarkdownDocument(props.markdown);
36 const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
37 return {
38 ...content,
39 attachments: content.attachments.filter((attachment) => !isBotBadgeImage(attachment.uri)),
40 };
41 }, [props.markdown]);
42 const gallery = useMemo<FilePreviewItem[]>(
43 () =>
44 content.attachments
45 .filter((attachment) => attachment.kind === "image")
46 .map((attachment) => {
47 return {
48 ...pullRequestImageSource(attachment.uri, mediaContext),
49 name: new URL(attachment.uri).pathname.split("/").pop() || "Image",
50 };
51 }),
52 [content, mediaContext],
53 );
54 const canCollapse = bodyHeight > COLLAPSED_HEIGHT;
55 const collapsed = canCollapse && !expanded;
56 return (
57 <View className="gap-5">
58 <View className="gap-3">
59 <PullRequestSectionHeading>Description</PullRequestSectionHeading>
60 {props.markdown.trim() ? (
61 <View className="overflow-hidden">
62 <View
63 style={{
64 height: expanded ? bodyHeight : Math.min(bodyHeight, COLLAPSED_HEIGHT),
65 overflow: "hidden",
66 }}
67 >
68 {/* Measure the full body independently of the clipped viewport. */}
69 <View
70 style={{ position: "absolute", top: 0, left: 0, right: 0 }}
71 onLayout={(event) => setBodyHeight(event.nativeEvent.layout.height)}
72 >
73 <PullRequestMarkdown markdown={props.markdown} />
74 </View>
75 </View>
76 {collapsed ? (
77 <View
78 pointerEvents="none"
79 style={[StyleSheet.absoluteFill, { top: undefined, height: 80, zIndex: 1 }]}
80 >
81 <Svg width="100%" height={80}>
82 <Defs>
83 <LinearGradient
84 id="description-fade"
85 x1="0"
86 y1="0"
87 x2="0"
88 y2="80"
89 gradientUnits="userSpaceOnUse"
90 >
91 <Stop offset="0" stopColor={fadeColor} stopOpacity={0} />
92 <Stop offset="0.5" stopColor={fadeColor} stopOpacity={0.94} />
93 <Stop offset="0.72" stopColor={fadeColor} stopOpacity={1} />
94 <Stop offset="1" stopColor={fadeColor} stopOpacity={1} />
95 </LinearGradient>
96 </Defs>
97 <Rect width="100%" height="100%" fill="url(#description-fade)" />
98 </Svg>
99 <View
100 style={{
101 position: "absolute",
102 bottom: 0,
103 left: 0,
104 right: 0,
105 height: 44,
106 backgroundColor: fadeColor,
107 }}
108 />
109 </View>
110 ) : null}
111 {canCollapse ? (
112 <Pressable
113 accessibilityRole="button"
114 accessibilityState={{ expanded }}
115 accessibilityLabel={expanded ? "Collapse description" : "Expand description"}
116 onPress={() => {
117 if (expanded) props.onCollapse();
118 onExpandedChange(!expanded);
119 }}
120 style={
121 collapsed
122 ? { position: "absolute", bottom: 2, left: 0, right: 0, height: 44, zIndex: 2 }
123 : undefined
124 }
125 className="min-h-11 flex-row items-center justify-center gap-1 py-2 active:opacity-70"
126 >
127 <Text className="text-xs font-t3-medium text-foreground">
128 {expanded ? "Show less" : "Show more"}
129 </Text>
130 <SymbolView
131 name={expanded ? "chevron.up" : "chevron.down"}
132 size={10}
133 tintColorClassName="accent-foreground"
134 />
135 </Pressable>
136 ) : null}
137 </View>
138 ) : (
139 <Text className="text-sm text-foreground-muted">No description provided.</Text>
140 )}
141 </View>
142 {content.attachments.length ? (
143 <View className="gap-3">
144 <PullRequestSectionHeading>Attachments</PullRequestSectionHeading>
145 <ScrollView
146 horizontal
147 keyboardShouldPersistTaps="always"
148 scrollsToTop={false}
149 showsHorizontalScrollIndicator={false}
150 contentContainerStyle={{ gap: 12 }}
151 >
152 {content.attachments.map((attachment) =>
153 attachment.kind !== "image" ? (
154 <PullRequestMedia
155 key={attachment.uri}
156 url={attachment.uri}
157 kind={attachment.kind}
158 caption={attachment.caption}
159 compact
160 />
161 ) : (
162 <PullRequestImageAttachment
163 key={attachment.uri}
164 url={attachment.uri}
165 caption={attachment.caption}
166 source={
167 gallery[
168 content.attachments
169 .filter((item) => item.kind === "image")
170 .indexOf(attachment)
171 ]!
172 }
173 gallery={gallery}
174 />
175 ),
176 )}
177 </ScrollView>
178 </View>
179 ) : null}
180 </View>
181 );
182}