Skip to content
Source
Sign inSign up

PullRequestMarkdown.tsx

252 lines9.3 KB

1import {
2 splitPullRequestBody,
3 type PullRequestBodySegment,
4} from "@t3tools/client-runtime/pull-request-markdown";
5import { markdownTaskMarkerOffset } from "@t3tools/shared/markdownTaskList";
6import type { ReactNode } from "react";
7import { View } from "react-native";
8import { useProject } from "../../state/entities";
9import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
10import {
11 PullRequestMedia,
12 PullRequestMediaContext,
13 usePullRequestMediaUrl,
14} from "./PullRequestMedia";
15import { videoMimeType } from "@t3tools/shared/video";
16import { createContext, useContext, useMemo, useState } from "react";
17import {
18 changeRequestReferenceAutolinker,
19 resolveChangeRequestMediaUrl,
20} from "@t3tools/client-runtime/pull-request-markdown";
21import {
22 sourceControlClients,
23 type ReferenceAutolink,
24} from "@t3tools/client-runtime/source-control-clients";
25import { linkReferenceAutolinks } from "./pullRequestAutolinks";
26​
27import { useOpenPullRequest } from "../../native/useOpenPullRequest";
28import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
29import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
30import { markdownImageSources } from "../../lib/markdownImageSources";
31import { FilePreviewGallery, useFilePreviewGallery } from "../../components/ui/FilePreviewGallery";
32import { pullRequestImageSource } from "./pullRequestMediaSource";
33import {
34 FilePreviewModal,
35 type FilePreviewSource,
36 type FilePreviewItem,
37} from "../../components/ui/FilePreviewModal";
38import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
39import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
40import type { SelectableMarkdownTextProps } from "../../native/SelectableMarkdownText.types";
41​
42const AutolinksContext = createContext<((text: string) => ReadonlyArray<ReferenceAutolink>) | null>(
43 null,
44);
45const LineBreaksContext = createContext(false);
46const ChangeRequestUrlContext = createContext<string | null>(null);
47​
48export function PullRequestMarkdownRepository(props: {
49 /** The change request the markdown belongs to, whose host decides what its text links. */
50 readonly changeRequest: { readonly provider: string; readonly url: string } | null | undefined;
51 readonly params: PullRequestParams;
52 readonly children: ReactNode;
53}) {
54 const provider = props.changeRequest?.provider;
55 const url = props.changeRequest?.url;
56 const autolinks =
57 provider === undefined || url === undefined
58 ? null
59 : changeRequestReferenceAutolinker({ provider, url });
60 const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false;
61 const target = pullRequestTarget(props.params);
62 const project = useProject({
63 environmentId: target.environmentId,
64 projectId: target.input.projectId,
65 });
66 const cwd = project?.workspaceRoot;
67 const media = cwd
68 ? { environmentId: target.environmentId, cwd, repository: target.input.repository }
69 : null;
70 return (
71 <AutolinksContext value={autolinks}>
72 <LineBreaksContext value={lineBreaks}>
73 <ChangeRequestUrlContext value={url ?? null}>
74 <PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
75 </ChangeRequestUrlContext>
76 </LineBreaksContext>
77 </AutolinksContext>
78 );
80​
81function MarkdownImage(props: {
82 readonly href: string;
83 readonly alt: string | null;
84 readonly onPreview: (source: FilePreviewSource) => void;
85}) {
86 const media = usePullRequestMediaUrl(props.href);
87 const context = useContext(PullRequestMediaContext);
88 const withGallery = useFilePreviewGallery();
89 return (
90 <ThreadMarkdownImageView
91 uri={media.uri}
92 sourceKey={props.href}
93 cacheKey={media.cacheKey}
94 unavailable={media.unavailable || !/^https?:\/\//i.test(props.href)}
95 alt={props.alt}
96 onPressPreview={(source) =>
97 props.onPreview(
98 withGallery({
99 ...pullRequestImageSource(props.href, context),
100 name: source.name,
101 sourceIdentifier: source.sourceIdentifier,
102 }),
103 )
104 }
105 />
106 );
108​
109export function PullRequestMarkdown(props: {
110 readonly markdown: string;
111 readonly imageGroupMarkdown?: string;
112 readonly lineBreaks?: boolean;
113 /**
114 * `markerOffset` is into `markdown`. Returning false, or a tap that can't be traced back to
115 * the source, undoes the tick the renderer already drew.
116 */
117 readonly onTaskListChange?: (input: {
118 readonly markerOffset: number;
119 readonly checked: boolean;
120 }) => boolean;
121}) {
122 const mediaContext = useContext(PullRequestMediaContext);
123 const changeRequestUrl = useContext(ChangeRequestUrlContext);
124 const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
125 const [undoneTicks, setUndoneTicks] = useState(0);
126 const gallery: FilePreviewItem[] = markdownImageSources(
127 props.imageGroupMarkdown ?? props.markdown,
128 )
129 .map((href) => (changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href))
130 .filter(
131 (uri) =>
132 /^https?:\/\//i.test(uri) &&
133 videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null,
134 )
135 .map((uri) => pullRequestImageSource(uri, mediaContext));
136 const segmentProps = {
137 gallery,
138 lineBreaks: props.lineBreaks,
139 undoneTicks,
140 onTaskListChange: props.onTaskListChange,
141 onTaskListUndone: () => setUndoneTicks((count) => count + 1),
142 };
143 return (
144 <View style={{ gap: 12 }}>
145 {segments.map((segment) => (
146 <PullRequestBodySegmentView key={segment.id} segment={segment} {...segmentProps} />
147 ))}
148 </View>
149 );
151​
152function PullRequestBodySegmentView(props: {
153 readonly segment: PullRequestBodySegment;
154 readonly gallery: FilePreviewItem[];
155 readonly lineBreaks: boolean | undefined;
156 readonly undoneTicks: number;
157 readonly onTaskListChange:
158 | ((input: { readonly markerOffset: number; readonly checked: boolean }) => boolean)
159 | undefined;
160 readonly onTaskListUndone: () => void;
161}) {
162 const { segment, onTaskListChange } = props;
163 if (segment.kind === "markdown") {
164 return (
165 <PullRequestMarkdownContent
166 key={props.undoneTicks}
167 markdown={segment.text}
168 gallery={props.gallery}
169 lineBreaks={props.lineBreaks}
170 onTaskListItemPress={
171 onTaskListChange
172 ? (event) => {
173 const offset = markdownTaskMarkerOffset(
174 segment.text,
175 event.index,
176 event.text,
177 event.checked,
178 );
179 const kept =
180 offset !== null &&
181 onTaskListChange({
182 markerOffset: segment.sourceOffset + offset,
183 checked: event.checked,
184 });
185 if (!kept) props.onTaskListUndone();
186 }
187 : undefined
188 }
189 />
190 );
191 }
192 if (segment.kind === "attachment") {
193 return <PullRequestMedia url={segment.url} kind={segment.media} />;
194 }
195 return (
196 <View style={{ gap: 12 }}>
197 {segment.rows.map((cells, rowIndex) => (
198 // oxlint-disable-next-line react/no-array-index-key -- rows have no identity besides position
199 <View key={rowIndex} style={{ flexDirection: "row", gap: 12 }}>
200 {cells.map((cell, cellIndex) => (
201 // oxlint-disable-next-line react/no-array-index-key -- cells have no identity besides position
202 <View key={cellIndex} style={{ flex: 1, minWidth: 0, gap: 12 }}>
203 {cell.segments.map((cellSegment) => (
204 <PullRequestBodySegmentView key={cellSegment.id} {...props} segment={cellSegment} />
205 ))}
206 </View>
207 ))}
208 </View>
209 ))}
210 </View>
211 );
213​
214function PullRequestMarkdownContent(props: {
215 readonly markdown: string;
216 readonly gallery: FilePreviewItem[];
217 readonly lineBreaks: boolean | undefined;
218 readonly onTaskListItemPress?: SelectableMarkdownTextProps["onTaskListItemPress"];
219}) {
220 const hostLineBreaks = useContext(LineBreaksContext);
221 const autolinks = useContext(AutolinksContext);
222 const mediaContext = useContext(PullRequestMediaContext);
223 const changeRequestUrl = useContext(ChangeRequestUrlContext);
224 const resolveMediaUrl = (href: string) =>
225 changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href;
226 const markdown = linkReferenceAutolinks(props.markdown, autolinks);
227 const openPullRequest = useOpenPullRequest();
228 const openLink = (href: string) => {
229 void openPullRequest(href);
230 };
231 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
232 const renderImage: MarkdownImageRenderer = (image) => (
233 <MarkdownImage href={resolveMediaUrl(image.href)} alt={image.alt} onPreview={setPreview} />
234 );
235 const styles = useMarkdownPreviewStyles();
236 return (
237 <FilePreviewGallery value={props.gallery}>
238 <SelectableMarkdownText
239 markdown={markdown}
240 preserveSoftBreaks={props.lineBreaks ?? hostLineBreaks}
241 textStyle={styles.nativeTextStyle}
242 onLinkPress={openLink}
243 renderImage={renderImage}
244 onImagePress={(href) =>
245 setPreview(pullRequestImageSource(resolveMediaUrl(href), mediaContext))
246 }
247 onTaskListItemPress={props.onTaskListItemPress}
248 />
249 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
250 </FilePreviewGallery>
251 );