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, type ComponentProps, type ReactNode } 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/ui/AppSymbol";
14import { AppText as Text } from "../../components/ui/AppText";
15import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
16import type { FilePreviewItem } from "../../components/ui/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 readonly footer?: ReactNode;
29 readonly onTaskListChange?: ComponentProps<typeof PullRequestMarkdown>["onTaskListChange"];
30}) {
31 const { expanded, onExpandedChange } = props;
32 const [bodyHeight, setBodyHeight] = useState(0);
33 const mediaContext = useContext(PullRequestMediaContext);
34 const { themeVariables: colors } = useAppearancePreferences();
35 const fadeColor = colors[Platform.OS === "android" ? "--color-sheet-solid" : "--color-screen"];
36 const content = useMemo(() => {
37 const document = parseMarkdownDocument(props.markdown);
38 const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
39 return {
40 ...content,
41 attachments: content.attachments.filter((attachment) => !isBotBadgeImage(attachment.uri)),
42 };
43 }, [props.markdown]);
44 const gallery: FilePreviewItem[] = 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 const canCollapse = bodyHeight > COLLAPSED_HEIGHT;
53 const collapsed = canCollapse && !expanded;
54 return (
55 <View className="gap-5">
56 <View className="gap-3">
57 <PullRequestSectionHeading>Description</PullRequestSectionHeading>
58 {props.markdown.trim() ? (
59 <View className="overflow-hidden">
60 <View
61 style={{
62 height: expanded ? bodyHeight : Math.min(bodyHeight, COLLAPSED_HEIGHT),
63 overflow: "hidden",
64 }}
65 >
66 <View
67 style={{ position: "absolute", top: 0, left: 0, right: 0 }}
68 onLayout={(event) => setBodyHeight(event.nativeEvent.layout.height)}
69 >
70 <PullRequestMarkdown
71 markdown={props.markdown}
72 onTaskListChange={props.onTaskListChange}
73 />
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 {props.footer}
142 </View>
143 {content.attachments.length ? (
144 <View className="gap-3">
145 <PullRequestSectionHeading>Attachments</PullRequestSectionHeading>
146 <ScrollView
147 horizontal
148 keyboardShouldPersistTaps="always"
149 scrollsToTop={false}
150 showsHorizontalScrollIndicator={false}
151 contentContainerStyle={{ gap: 12 }}
152 >
153 {content.attachments.map((attachment) =>
154 attachment.kind !== "image" ? (
155 <PullRequestMedia
156 key={attachment.uri}
157 url={attachment.uri}
158 kind={attachment.kind}
159 caption={attachment.caption}
160 compact
161 />
162 ) : (
163 <PullRequestImageAttachment
164 key={attachment.uri}
165 url={attachment.uri}
166 caption={attachment.caption}
167 source={
168 gallery[
169 content.attachments
170 .filter((item) => item.kind === "image")
171 .indexOf(attachment)
172 ]!
173 }
174 gallery={gallery}
175 />
176 ),
177 )}
178 </ScrollView>
179 </View>
180 ) : null}
181 </View>
182 );
183}