| 1 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; |
| 2 | import { |
| 3 | PullRequestImageAttachment, |
| 4 | PullRequestMedia, |
| 5 | PullRequestMediaContext, |
| 6 | } from "./PullRequestMedia"; |
| 7 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; |
| 8 | import { useContext, useMemo, useState } from "react"; |
| 9 | import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; |
| 10 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 11 | import { pullRequestImageSource } from "./pullRequestMediaSource"; |
| 12 | import { parseMarkdownDocument } from "../../lib/markdownDocument"; |
| 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | import { AppText as Text } from "../../components/AppText"; |
| 15 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 16 | import type { FilePreviewItem } from "../../components/FilePreviewModal"; |
| 17 | import { isBotBadgeImage } from "./botBadgeImages"; |
| 18 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 19 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 20 | |
| 21 | const COLLAPSED_HEIGHT = 240; |
| 22 | |
| 23 | export 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 | } |